diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f329734 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-specific directories and binaries. +/dist +/dump +/quickbase-cli diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..866dfc5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Quickbase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6b7beb2 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: build +build: + go build + +.PHONY: install +install: + go get ./... + +.PHONY: update +update: + go get -u ./... + +.PHONY: test +test: install + go test -v ./qbclient + +.PHONY: dist +dist: + GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o ./dist/darwin/quickbase-cli + upx -qqq ./dist/darwin/quickbase-cli + GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ./dist/linux/quickbase-cli + upx -qqq ./dist/linux/quickbase-cli + GOOS=windows GOARCH=386 go build -ldflags="-s -w" -o ./dist/windows/quickbase-cli.exe + upx -qqq ./dist/windows/quickbase-cli.exe + +.PHONY: clean +clean: + rm -f quickbase-cli + rm -f dist/darwin/* + rm -f dist/linux/* + rm -f dist/windows/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..f33af60 --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ +# Quickbase Command Line Interface + +The Quickbase Command Line Interface (CLI) is a tool to manage your Quickbase applications. + +## Overview + +The Quickbase CLI consumes Quickbase's RESTful API, so the commands should feel familiar to those versed in the [API docs](https://developer.quickbase.com/). In addition to being an easy way to consume the RESTful API, this tool is much more than a simple wrapper around it. Using the Quickbase CLI gives you the following benefits: + +* Configuration/credential management with profiles for different realms and apps +* Resiliency through [backoff retries](https://en.wikipedia.org/wiki/Exponential_backoff) +* Security through tools that mask sensitive information such as Quickbase tokens +* Scriptability through parsable output, I/O redirection, and [JMESPath filtering](https://jmespath.org/) +* Observability through logging that implements [best practices](https://dev.splunk.com/enterprise/docs/developapps/logging/loggingbestpractices/) +* Debugging through request/response dumping to see what is sent/received over the wire +* Delighters that make it easier to work with data + * Simple Query syntax (e.g., `--where 3=2`, `--where 2` which both equal `--where {'3'.EX.'2'}`) + * Natural language processing for various data types, e.g., dates, durations, and addresses (planned). + +## Installation + +Download the latest binary for you platform from the [Releases](https://github.com/QuickBase/quiickbase-cli/releases) section to a directory that i in your systems [PATH](https://en.wikipedia.org/wiki/PATH_(variable)). + +### Building From Source + +With a [correctly configured](https://golang.org/doc/install#install) Go toolchain: + +```sh +go get github.com/QuickBase/quickbase-cli +``` + +## Configuration + +Configuration is read from command-line options, environment variables, and a configuration file in that order of precedence. You are advised to set up a configuration file using the command below, which will prompt for your realm hostname, user token, and an optional application ID. + +```sh +quickbase-cli config setup +``` + +The configuration is written to a file named `.config/quickbase/config.yml` under your home directory, which you can edit to add additional configuration sets called **profiles**: + +```yml +default: + realm_hostname: example1.quickbase.com + user_token: b3b6se_mzif_dy36********************hi7b + app_id: bqgruir3g + +another_realm: + realm_hostname: example2.quickbase.com + user_token: b3b6se_uyp_iybv********************js2k +``` + +The `default` profile is used unless the `QUICKBASE_PROFILE` environment variable or `--profile` command line option specify another value, such as `another_realm`. + +Run the following command to dump the configuration values for the active profile: + +```sh +quickbase-cli config dump +{ + "realm_hostname": "example1.quickbase.com", + "user_token": "b3b6se_mzif_dy36********************hi7b", + "app_id": "bqgruir3g" +} +``` + +You can also set environment variables for common options, e.g., app IDs, table IDs, and field IDs. This makes it easy to chain together a string of commands that act on the same resource: + +```sh +export QUICKBASE_TABLE_ID=bqgruir7z + +# The commands below use "bqgruir7z" for the --to and --from options. +quickbase-cli records insert --data '6="Another Record" 7=3' +quickbase-cli records query --select 6 --where '6="Another Record"' +quickbase-cli records delete --where '6="Another Record"' +``` + +## Usage + +#### Command Format + +Exmaple command that gets an app definition: + +```sh +quickbase-cli app get --app-id bqgruir3g +``` + +```json +{ + "id": "bqgruir3g", + "name": "New API Test", + "timeZone": "(UTC-08:00) Pacific Time (US \u0026 Canada)", + "dateFormat": "MM-DD-YYYY", + "created": "2020-11-03T19:33:01Z", + "updated": "2020-11-03T19:33:01Z", + "variables": [ + { + "name": "var1", + "value": "Test variable value" + } + ] +} +``` + +#### Querying For Records + +Example command that queries for records (where Record #ID is 2): + +```sh +quickbase-cli records query --select 6,7,8 --from bqgruir7z --where '{3.EX.2}' +``` + +```json +{ + "data": [ + { + "6": { + "value": "Record Two" + }, + "7": { + "value": 2 + }, + "8": { + "value": [ + "One", + "Two" + ] + } + } + ], + "fields": [ + { + "id": 6, + "label": "Title", + "type": "text" + }, + { + "id": 7, + "label": "Number", + "type": "numeric" + }, + { + "id": 8, + "label": "List", + "type": "multitext" + } + ], + "metadata": { + "totalRecords": 1, + "numRecords": 1, + "numFields": 3, + "skip": 0 + } +} +``` + +You can also use simplified query syntax for basic queries. The following command queries for records where field 6 equals "Record One" and field 7 equals 2: + +```sh +quickbase-cli records query --select 6,7,8 --from bqgruir7z --where '6="Record Two" 7=2' +``` + +Just passing a number will find a record by its ID: + +```sh +quickbase-cli records query --select 6,7,8 --from bqgruir7z --where 2 +``` + +#### Creating Records + +Example command that creates a record where field 6 equals "Another Record" and field 7 equals 3: + +```sh +quickbase-cli records insert --to bqgruir7z --data '6="Another Record" 7=3' +``` + +```json +{ + "metadata": { + "createdRecordIds": [ + 7 + ], + "totalNumberOfRecordsProcessed": 1, + "unchangedRecordIds": [], + "updatedRecordIds": [] + } +} +``` + +#### Transforming Output + +[JMESPath](https://jmespath.org/) is a powerful query language for JSON. You can apply JMESPath filters to transform the output of commands to make the data easier to work with. For example, let say you want to get only a list of table names in an app sorted alphabetically. To accomplish this, you can apply a JMESPath filter using the `--filter` option to the command below: + +```sh +quickbase-cli table list --app-id bqgruir3g --filter "tables[].name | sort(@) | {Tables: join(', ', @)}" +``` + +```json +{ + "Tables": "Fields, Records, Tasks" +} +``` + +### Global Options + +#### -h, --help + +Returns help for commands. + +#### -q, --quiet + +Suppress output written to STDOUT. + +#### -l, --log-level + +Pass `--log-level debug` to get information useful for debugging. Log messages are written to STDERR, so you can redirect the logs using `2>` without disrupting the normal output. + +Valid log levels are `debug`, `info`, `error`, `notice`, `fatal`, and `none`. The default value is `none`. + +#### -f, --log-file + +Pass `--log-file ./qb.log` to write logs to the `./qb.log` file instead of STDERR. + +#### -d, --dump-dir + +Pass `--dump-dir ./dump` to write the requests and responses sent over the wire as text files in the directory. The filenames are prefixed with the timestamp and contain the transaction id that can be found in the `transid` context in log messages. All tokens are maked for security. + +## Other Resources + +The [./jq](https://stedolan.github.io/jq/) tool compliments the Quickbase CLI nicely and makes it easier to work with the output. diff --git a/cmd/app.go b/cmd/app.go new file mode 100644 index 0000000..d37cb42 --- /dev/null +++ b/cmd/app.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var appCmd = &cobra.Command{ + Use: "app", + Short: "Apps resources", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + rootCmd.AddCommand(appCmd) +} diff --git a/cmd/app_copy.go b/cmd/app_copy.go new file mode 100644 index 0000000..a7bfc3a --- /dev/null +++ b/cmd/app_copy.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var appCopyCfg *viper.Viper + +var appCopyCmd = &cobra.Command{ + Use: "copy", + Short: "Copy an app", + + Args: func(cmd *cobra.Command, args []string) error { + err := globalCfg.Validate() + if err == nil { + globalCfg.SetDefaultAppID(appCopyCfg) + qbcli.SetOptionFromArg(appCopyCfg, args, 0, qbclient.OptionAppID) + } + return err + }, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.CopyAppInput{Properties: &qbclient.CopyAppInputProperties{}} + qbcli.GetOptions(ctx, logger, input, appCopyCfg) + + output, err := qb.CopyApp(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + appCopyCfg, flags = cliutil.AddCommand(appCmd, appCopyCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.CopyAppInput{Properties: &qbclient.CopyAppInputProperties{}}) +} diff --git a/cmd/app_create.go b/cmd/app_create.go new file mode 100644 index 0000000..23cafa6 --- /dev/null +++ b/cmd/app_create.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var appCreateCfg *viper.Viper + +var appCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an app", + + Args: func(cmd *cobra.Command, args []string) error { + return globalCfg.Validate() + }, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.CreateAppInput{} + qbcli.GetOptions(ctx, logger, input, appCreateCfg) + + output, err := qb.CreateApp(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + appCreateCfg, flags = cliutil.AddCommand(appCmd, appCreateCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.CreateAppInput{}) +} diff --git a/cmd/app_delete.go b/cmd/app_delete.go new file mode 100644 index 0000000..64f970a --- /dev/null +++ b/cmd/app_delete.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var appDeleteCfg *viper.Viper + +var appDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete an app", + + Args: func(cmd *cobra.Command, args []string) error { + err := globalCfg.Validate() + if err == nil { + globalCfg.SetDefaultAppID(appDeleteCfg) + qbcli.SetOptionFromArg(appDeleteCfg, args, 0, qbclient.OptionAppID) + } + return err + }, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.DeleteAppInput{} + qbcli.GetOptions(ctx, logger, input, appDeleteCfg) + + output, err := qb.DeleteApp(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + appDeleteCfg, flags = cliutil.AddCommand(appCmd, appDeleteCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.DeleteAppInput{}) +} diff --git a/cmd/app_events.go b/cmd/app_events.go new file mode 100644 index 0000000..8ffdd5f --- /dev/null +++ b/cmd/app_events.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var appEventsCfg *viper.Viper + +var appEventsCmd = &cobra.Command{ + Use: "events", + Short: "List app events", + + Args: func(cmd *cobra.Command, args []string) error { + err := globalCfg.Validate() + if err == nil { + globalCfg.SetDefaultAppID(appEventsCfg) + qbcli.SetOptionFromArg(appEventsCfg, args, 0, qbclient.OptionAppID) + } + return err + }, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.ListAppEventsInput{} + qbcli.GetOptions(ctx, logger, input, appEventsCfg) + + output, err := qb.ListAppEvents(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + appEventsCfg, flags = cliutil.AddCommand(appCmd, appEventsCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.ListAppEventsInput{}) +} diff --git a/cmd/app_get.go b/cmd/app_get.go new file mode 100644 index 0000000..f9ec6bd --- /dev/null +++ b/cmd/app_get.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var appGetCfg *viper.Viper + +var appGetCmd = &cobra.Command{ + Use: "get", + Short: "Get an app definition", + + Args: func(cmd *cobra.Command, args []string) (err error) { + err = globalCfg.Validate() + if err == nil { + globalCfg.SetDefaultAppID(appGetCfg) + qbcli.SetOptionFromArg(appGetCfg, args, 0, qbclient.OptionAppID) + } + return + }, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.GetAppInput{} + qbcli.GetOptions(ctx, logger, input, appGetCfg) + + output, err := qb.GetApp(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + appGetCfg, flags = cliutil.AddCommand(appCmd, appGetCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.GetAppInput{}) +} diff --git a/cmd/app_list.go b/cmd/app_list.go new file mode 100644 index 0000000..b25a8ba --- /dev/null +++ b/cmd/app_list.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var appListCfg *viper.Viper + +var appListCmd = &cobra.Command{ + Use: "list", + Short: "List apps", + + Args: func(cmd *cobra.Command, args []string) error { + return globalCfg.Validate() + }, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.ListAppsInput{} + qbcli.GetOptions(ctx, logger, input, appListCfg) + + output, err := qb.ListApps(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + appListCfg, flags = cliutil.AddCommand(appCmd, appListCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.ListAppsInput{}) +} diff --git a/cmd/app_open.go b/cmd/app_open.go new file mode 100644 index 0000000..d28364e --- /dev/null +++ b/cmd/app_open.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "net/url" + + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/pkg/browser" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var appOpenCfg *viper.Viper + +var appOpenCmd = &cobra.Command{ + Use: "open", + Short: "Open an app's homepage in a browser", + + Args: func(cmd *cobra.Command, args []string) error { + err := globalCfg.Validate() + if err == nil { + globalCfg.SetDefaultAppID(appOpenCfg) + qbcli.SetOptionFromArg(appOpenCfg, args, 0, qbclient.OptionAppID) + } + return err + }, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, _ := qbcli.NewLogger(cmd, globalCfg) + appID := appOpenCfg.GetString(qbclient.OptionAppID) + url := fmt.Sprintf("https://%s/db/%s", globalCfg.RealmHostname(), url.PathEscape(appID)) + err := browser.OpenURL(url) + logger.FatalIfError(ctx, "error opening app in browser", err) + }, +} + +func init() { + var flags *cliutil.Flagger + appOpenCfg, flags = cliutil.AddCommand(appCmd, appOpenCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionAppID, "", "", qbcli.OptionAppIDDescription) +} diff --git a/cmd/app_update.go b/cmd/app_update.go new file mode 100644 index 0000000..04a4fd1 --- /dev/null +++ b/cmd/app_update.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var appUpdateCfg *viper.Viper + +var appUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an app", + + Args: func(cmd *cobra.Command, args []string) error { + err := globalCfg.Validate() + if err == nil { + globalCfg.SetDefaultAppID(appUpdateCfg) + qbcli.SetOptionFromArg(appUpdateCfg, args, 0, qbclient.OptionAppID) + } + return err + }, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.UpdateAppInput{} + qbcli.GetOptions(ctx, logger, input, appUpdateCfg) + + output, err := qb.UpdateApp(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + appUpdateCfg, flags = cliutil.AddCommand(appCmd, appUpdateCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.UpdateAppInput{}) +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..7bdc3fe --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// configCmd represents the app command +var configCmd = &cobra.Command{ + Use: "config", + Short: "Configuration commands", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + rootCmd.AddCommand(configCmd) +} diff --git a/cmd/config_dump.go b/cmd/config_dump.go new file mode 100644 index 0000000..8cc82bf --- /dev/null +++ b/cmd/config_dump.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/spf13/cobra" +) + +// configDumpCmd represents the app command +var configDumpCmd = &cobra.Command{ + Use: "dump", + Short: "Print the current configuration", + Args: configDumpCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, _ := qbcli.NewLogger(cmd, globalCfg) + + config := qbclient.ConfigFileProfile{ + RealmHostname: globalCfg.RealmHostname(), + UserToken: qbclient.MaskUserTokenString(globalCfg.UserToken()), + TemporaryToken: qbclient.MaskUserTokenString(globalCfg.TemporaryToken()), + AppID: globalCfg.DefaultAppID(), + TableID: globalCfg.DefaultTableID(), + FieldID: globalCfg.DefaultFieldID(), + } + + qbcli.Render(ctx, logger, cmd, globalCfg, config, nil) + }, +} + +func init() { + configCmd.AddCommand(configDumpCmd) +} + +func configDumpCmdValidate(cmd *cobra.Command, args []string) error { + return globalCfg.Validate() +} diff --git a/cmd/config_profiles.go b/cmd/config_profiles.go new file mode 100644 index 0000000..5bb6bb5 --- /dev/null +++ b/cmd/config_profiles.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// configProfilesCfg represents the app command +var configProfilesCfg = &cobra.Command{ + Use: "profiles", + Short: "List profiles in the config file", + Args: configProfilesCfgValidate, + + Run: func(cmd *cobra.Command, args []string) { + + }, +} + +func init() { + configCmd.AddCommand(configProfilesCfg) +} + +func configProfilesCfgValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.ReadInConfig(); err != nil { + return err + } + return nil +} diff --git a/cmd/config_setup.go b/cmd/config_setup.go new file mode 100644 index 0000000..773c175 --- /dev/null +++ b/cmd/config_setup.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "fmt" + + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" +) + +// configSetupCmd represents the app command +var configSetupCmd = &cobra.Command{ + Use: "setup", + Short: "Run initial setup of a configuration file", + Args: configSetupCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + + // Bail if there is an existing configuration file. + filepath := qbclient.Filepath(globalCfg.ConfigDir(), qbclient.ConfigFilename) + if qbclient.FileExists(filepath) { + fmt.Printf("Configuration file already created at %s.\nPlease edit the file directly.\n", filepath) + return + } + + hostname, err := qbcli.Prompt("Realm Hostname: ", qbclient.ValidateHostname) + cliutil.HandleError(cmd, err, "error reading realm hostname") + + usertoken, err := qbcli.Prompt("User Token: ", qbclient.ValidateNotEmptyFn("user token")) + cliutil.HandleError(cmd, err, "error reading user token") + + appID, err := qbcli.Prompt("App ID (optional): ", qbclient.NoValidation) + cliutil.HandleError(cmd, err, "error reading app id") + + cf := make(map[string]*qbclient.ConfigFileProfile, 1) + cf["default"] = &qbclient.ConfigFileProfile{ + RealmHostname: hostname, + UserToken: usertoken, + AppID: appID, + } + + err = qbclient.WriteConfigFile(globalCfg.ConfigDir(), cf) + cliutil.HandleError(cmd, err, "error writing config file") + + fmt.Printf("\nConfig file written to %s.\n", filepath) + }, +} + +func init() { + configCmd.AddCommand(configSetupCmd) +} + +func configSetupCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.ReadInConfig(); err != nil { + return err + } + return nil +} diff --git a/cmd/field.go b/cmd/field.go new file mode 100644 index 0000000..1fc86e0 --- /dev/null +++ b/cmd/field.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// fieldCmd represents the app command +var fieldCmd = &cobra.Command{ + Use: "field", + Short: "Fields resources", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + rootCmd.AddCommand(fieldCmd) +} diff --git a/cmd/field_create.go b/cmd/field_create.go new file mode 100644 index 0000000..ff5cbbf --- /dev/null +++ b/cmd/field_create.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var fieldCreateCfg *viper.Viper + +// fieldCreateCmd represents the app get command +var fieldCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a field in a table", + Args: fieldCreateCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.CreateFieldInput{ + TableID: fieldCreateCfg.GetString(qbclient.OptionTableID), + Field: qbcli.NewFieldFromOptions(fieldCreateCfg), + Properties: &qbclient.CreateFieldInputProperties{ + FieldProperties: qbcli.NewPropertiesFromOptions(fieldCreateCfg), + }, + } + + if !fieldCreateCfg.GetBool("dry-run") { + output, err := qb.CreateField(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + } else { + cliutil.PrintJSON(input) + } + }, +} + +func init() { + var flags *cliutil.Flagger + fieldCreateCfg, flags = cliutil.AddCommand(fieldCmd, fieldCreateCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) + flags.Bool("dry-run", "", false, "print input but don't send it") + + qbcli.SetFieldOptions(flags) +} + +func fieldCreateCmdValidate(cmd *cobra.Command, args []string) (err error) { + if err = globalCfg.Validate(); err == nil { + globalCfg.SetDefaultTableID(fieldCreateCfg) + qbcli.SetOptionFromArg(fieldCreateCfg, args, 0, qbclient.OptionTableID) + } + return +} diff --git a/cmd/field_delete.go b/cmd/field_delete.go new file mode 100644 index 0000000..6eb8cda --- /dev/null +++ b/cmd/field_delete.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "strconv" + + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var fieldDeleteCfg *viper.Viper + +// fieldDeleteCmd represents the app get command +var fieldDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete one or many fields in a table", + Args: fieldDeleteCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + fids, err := qbcli.ParseFieldList(fieldDeleteCfg.GetString(qbclient.OptionFieldID)) + cliutil.HandleError(cmd, err, "field-id option invalid") + + input := &qbclient.DeleteFieldsInput{ + TableID: fieldDeleteCfg.GetString(qbclient.OptionTableID), + FieldIDs: fids, + } + + output, err := qb.DeleteFields(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + fieldDeleteCfg, flags = cliutil.AddCommand(fieldCmd, fieldDeleteCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) + flags.String(qbclient.OptionFieldID, "", "", qbcli.OptionFieldIDDescription) +} + +func fieldDeleteCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + fieldDeleteCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Set the default Field ID if configured. + if fieldID := globalCfg.DefaultFieldID(); fieldID != 0 { + fieldDeleteCfg.SetDefault(qbclient.OptionFieldID, strconv.Itoa(fieldID)) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + fieldDeleteCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + // Use args[1] as value for the Field ID. + if len(args) > 1 { + fieldDeleteCfg.SetDefault(qbclient.OptionFieldID, args[1]) + } + + return nil +} diff --git a/cmd/field_get.go b/cmd/field_get.go new file mode 100644 index 0000000..6ffa45c --- /dev/null +++ b/cmd/field_get.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var fieldGetCfg *viper.Viper + +// fieldGetCmd represents the app get command +var fieldGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a field definition", + Args: fieldGetCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.GetFieldInput{ + TableID: fieldGetCfg.GetString(qbclient.OptionTableID), + FieldID: fieldGetCfg.GetInt(qbclient.OptionFieldID), + } + + output, err := qb.GetField(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + fieldGetCfg, flags = cliutil.AddCommand(fieldCmd, fieldGetCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) + flags.Int(qbclient.OptionFieldID, "", 0, qbcli.OptionFieldIDDescription) +} + +func fieldGetCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default TableID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + fieldGetCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Set the default FieldID if configured. + if fieldID := globalCfg.DefaultFieldID(); fieldID != 0 { + fieldGetCfg.SetDefault(qbclient.OptionFieldID, fieldID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + fieldGetCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + // Use args[1] as value for the Field ID. + if len(args) > 1 { + fieldGetCfg.SetDefault(qbclient.OptionFieldID, args[1]) + } + + return nil +} diff --git a/cmd/field_list.go b/cmd/field_list.go new file mode 100644 index 0000000..e63711a --- /dev/null +++ b/cmd/field_list.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var fieldListCfg *viper.Viper + +// fieldListCmd represents the app get command +var fieldListCmd = &cobra.Command{ + Use: "list", + Short: "List fields in a table", + Args: fieldListCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.ListFieldsInput{ + TableID: fieldListCfg.GetString(qbclient.OptionTableID), + IncludeFieldPermissions: fieldListCfg.GetBool("include-field-permissions"), + } + + output, err := qb.ListFields(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + fieldListCfg, flags = cliutil.AddCommand(fieldCmd, fieldListCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) + flags.Bool("include-field-permissions", "", false, "return custom permissions for the fields") +} + +func fieldListCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + fieldListCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + fieldListCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + return nil +} diff --git a/cmd/field_open.go b/cmd/field_open.go new file mode 100644 index 0000000..fcded04 --- /dev/null +++ b/cmd/field_open.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + "net/url" + + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/pkg/browser" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var fieldOpenCfg *viper.Viper + +// fieldOpenCmd represents the app get command +var fieldOpenCmd = &cobra.Command{ + Use: "open", + Short: "Modify a field in a browser", + Args: fieldOpenCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, _ := qbcli.NewLogger(cmd, globalCfg) + + tableID := fieldOpenCfg.GetString(qbclient.OptionTableID) + fieldID := fieldOpenCfg.GetInt(qbclient.OptionFieldID) + + url := fmt.Sprintf("https://%s/db/%s?a=mf&fid=%v&chain=1", globalCfg.RealmHostname(), url.PathEscape(tableID), fieldID) + err := browser.OpenURL(url) + logger.FatalIfError(ctx, "error opening app in browser", err) + }, +} + +func init() { + var flags *cliutil.Flagger + fieldOpenCfg, flags = cliutil.AddCommand(fieldCmd, fieldOpenCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) + flags.Int(qbclient.OptionFieldID, "", 0, qbcli.OptionFieldIDDescription) +} + +func fieldOpenCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + fieldOpenCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Set the default Field ID if configured. + if fieldID := globalCfg.DefaultFieldID(); fieldID != 0 { + fieldOpenCfg.SetDefault(qbclient.OptionFieldID, fieldID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + fieldOpenCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + // Use args[1] as value for the Field ID. + if len(args) > 1 { + fieldOpenCfg.SetDefault(qbclient.OptionFieldID, args[1]) + } + + return nil +} diff --git a/cmd/field_update.go b/cmd/field_update.go new file mode 100644 index 0000000..7761019 --- /dev/null +++ b/cmd/field_update.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var fieldUpdateCfg *viper.Viper + +// fieldUpdateCmd represents the app get command +var fieldUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update a field in a table", + Args: fieldUpdateCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.UpdateFieldInput{ + TableID: fieldUpdateCfg.GetString(qbclient.OptionTableID), + Field: qbcli.NewFieldFromOptions(fieldUpdateCfg), + Properties: &qbclient.UpdateFieldInputProperties{ + FieldProperties: qbcli.NewPropertiesFromOptions(fieldUpdateCfg), + }, + } + + if !fieldUpdateCfg.GetBool("dry-run") { + output, err := qb.UpdateField(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + } else { + cliutil.PrintJSON(input) + } + }, +} + +func init() { + var flags *cliutil.Flagger + fieldUpdateCfg, flags = cliutil.AddCommand(fieldCmd, fieldUpdateCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) + flags.Int(qbclient.OptionFieldID, "", 0, qbcli.OptionFieldIDDescription) + flags.Bool("dry-run", "", false, "print input but don't sent it") + + qbcli.SetFieldOptions(flags) +} + +func fieldUpdateCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default TableID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + fieldUpdateCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Set the default FieldID if configured. + if fieldID := globalCfg.DefaultFieldID(); fieldID != 0 { + fieldUpdateCfg.SetDefault(qbclient.OptionFieldID, fieldID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + fieldUpdateCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + // Use args[1] as value for the Field ID. + if len(args) > 1 { + fieldUpdateCfg.SetDefault(qbclient.OptionFieldID, args[1]) + } + + return nil +} diff --git a/cmd/records.go b/cmd/records.go new file mode 100644 index 0000000..2fafd93 --- /dev/null +++ b/cmd/records.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// recordsCmd represents the app command +var recordsCmd = &cobra.Command{ + Use: "records", + Short: "Records resources", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + rootCmd.AddCommand(recordsCmd) +} diff --git a/cmd/records_delete.go b/cmd/records_delete.go new file mode 100644 index 0000000..e1eff88 --- /dev/null +++ b/cmd/records_delete.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var recordsDeleteCfg *viper.Viper + +// recordsDeleteCmd represents the app get command +var recordsDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete records in a table", + Args: recordsDeleteCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + where := qbcli.ParseQuery(recordsDeleteCfg.GetString("where")) + ctx = cliutil.ContextWithLogTag(ctx, "query", where) + logger.Debug(ctx, "query formatted") + + input := &qbclient.DeleteRecordsInput{ + From: recordsDeleteCfg.GetString("from"), + Where: where, + } + + output, err := qb.DeleteRecords(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + recordsDeleteCfg, flags = cliutil.AddCommand(recordsCmd, recordsDeleteCmd, qbclient.EnvPrefix) + + flags.String("from", "", "", qbcli.OptionTableIDDescription) + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription+" (alias for \"from\")") + flags.String("where", "", "", "filter, using the Quick Base query language, which determines the records to delete (required)") +} + +func recordsDeleteCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + recordsDeleteCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + recordsDeleteCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + // This is how we handle option alises. I wish Viper's aliases worked with Cobra! + recordsDeleteCfg.SetDefault("from", recordsDeleteCfg.GetString(qbclient.OptionTableID)) + + return nil +} diff --git a/cmd/records_insert.go b/cmd/records_insert.go new file mode 100644 index 0000000..f98aa41 --- /dev/null +++ b/cmd/records_insert.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "strconv" + + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/exp/errors/fmt" +) + +var recordsInsertCfg *viper.Viper + +// recordsInsertCmd represents the app get command +var recordsInsertCmd = &cobra.Command{ + Use: "insert", + Short: "Insert and/or update records in a table", + Args: recordsInsertCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + tableID := recordsInsertCfg.GetString("to") + tmap, err := qbcli.GetFieldTypeMap(qb, tableID) + logger.FatalIfError(ctx, "error getting field types", err) + + record := &qbclient.Record{} + data := cliutil.ParseKeyValue(recordsInsertCfg.GetString("data")) + for fidstr, val := range data { + + // TODO check for field alias + fid, err := strconv.Atoi(fidstr) + cliutil.HandleError(cmd, err, fmt.Sprintf("invalid fid (%s)", fidstr)) + + ftype, ok := tmap[fid] + if !ok { + cliutil.HandleError(cmd, fmt.Errorf("field not in table (fid %s)", fidstr), "") + } + + v, err := qbclient.NewValueFromString(val, ftype) + cliutil.HandleError(cmd, err, fmt.Sprintf("invalid value (fid %s)", fidstr)) + record.SetValue(fid, v) + } + + fids, err := qbcli.ParseFieldList(recordsInsertCfg.GetString("fields-to-return")) + cliutil.HandleError(cmd, err, "invalid option (fields-to-return)") + + input := &qbclient.InsertRecordsInput{ + To: recordsInsertCfg.GetString("to"), + MergeFieldID: recordsInsertCfg.GetInt("merge-field-id"), + FieldsToReturn: fids, + } + input.SetRecords([]*qbclient.Record{record}) + + output, err := qb.InsertRecords(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + recordsInsertCfg, flags = cliutil.AddCommand(recordsCmd, recordsInsertCmd, qbclient.EnvPrefix) + + flags.String("to", "", "", qbcli.OptionTableIDDescription) + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription+" (alias for \"to\")") + flags.String("data", "", "", "record data to create") + flags.Int("merge-field-id", "", 0, "merge field id") + flags.String("fields-to-return", "", "", "comma-separated list of fields returned in the response for any updated or added records") +} + +func recordsInsertCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + recordsInsertCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + recordsInsertCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + // This is how we handle option alises. I wish Viper's aliases worked with Cobra! + recordsInsertCfg.SetDefault("to", recordsInsertCfg.GetString(qbclient.OptionTableID)) + + return nil +} diff --git a/cmd/records_query.go b/cmd/records_query.go new file mode 100644 index 0000000..db155af --- /dev/null +++ b/cmd/records_query.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var recordsQueryCfg *viper.Viper + +// recordsQueryCmd represents the record query command +var recordsQueryCmd = &cobra.Command{ + Use: "query", + Short: "Query records in a table using the Quick Base query language", + Args: recordsQueryCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + // Parse the list of fields IDs. + fids, err := qbcli.ParseFieldList(recordsQueryCfg.GetString("select")) + cliutil.HandleError(cmd, err, "select option invalid") + + // Parse the "where" clause. + where := qbcli.ParseQuery(recordsQueryCfg.GetString("where")) + ctx = cliutil.ContextWithLogTag(ctx, "query", where) + logger.Debug(ctx, "query formatted") + + input := &qbclient.QueryRecordsInput{ + Select: fids, + From: recordsQueryCfg.GetString("from"), + Where: where, + } + + // Add sortBy clause if passed. + sortBy := recordsQueryCfg.GetString("sort-by") + if sortBy != "" { + input.SortBy, err = qbcli.ParseSortBy(sortBy) + cliutil.HandleError(cmd, err, "sort-by option invalid") + } + + // Add groupBy clause if passed. + groupBy := recordsQueryCfg.GetString("group-by") + if groupBy != "" { + input.GroupBy, err = qbcli.ParseGroupBy(groupBy) + cliutil.HandleError(cmd, err, "group-by option invalid") + } + + output, err := qb.QueryRecords(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + recordsQueryCfg, flags = cliutil.AddCommand(recordsCmd, recordsQueryCmd, qbclient.EnvPrefix) + + flags.String("select", "", "", "comma-separated list of fields returned in the response (required)") + flags.String("from", "", "", qbcli.OptionTableIDDescription) + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription+" (alias for \"from\")") + flags.String("where", "", "", "filter, using the Quick Base query language, which determines the records to return (required)") + flags.String("group-by", "", "", "fields to group the records by, e.g., \"6 same-value\"") + flags.String("sort-by", "", "", "fields to sort the records by, e.g., \"7 DESC,8 ASC\"") +} + +func recordsQueryCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + recordsQueryCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + recordsQueryCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + // This is how we handle option alises. I wish Viper's aliases worked with Cobra! + recordsQueryCfg.SetDefault("from", recordsQueryCfg.GetString(qbclient.OptionTableID)) + + return nil +} diff --git a/cmd/relationship.go b/cmd/relationship.go new file mode 100644 index 0000000..bbaa6ce --- /dev/null +++ b/cmd/relationship.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// relationshipCmd represents the app command +var relationshipCmd = &cobra.Command{ + Use: "relationship", + Short: "relationship resources", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + rootCmd.AddCommand(relationshipCmd) +} diff --git a/cmd/relationship_create.go b/cmd/relationship_create.go new file mode 100644 index 0000000..18a39f4 --- /dev/null +++ b/cmd/relationship_create.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var relationshipCreateCfg *viper.Viper + +// relationshipCreateCmd represents the app get command +var relationshipCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a relationship between tables", + Args: relationshipCreateCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.CreateRelationshipInput{ + ChildTableID: relationshipCreateCfg.GetString("child-table-id"), + ParentTableID: relationshipCreateCfg.GetString("parent-table-id"), + } + + if label := relationshipCreateCfg.GetString("foreign-key-label"); label == "" { + input.ForeignKeyField = &qbclient.CreateRelationshipInputForeignKeyField{Label: label} + } + + // Parse the list of fields IDs. + fids, err := qbcli.ParseFieldList(relationshipCreateCfg.GetString("lookup-fields")) + cliutil.HandleError(cmd, err, "lookup-fields option invalid") + if len(fids) > 0 { + input.LookupFieldIDs = fids + } + + output, err := qb.CreateRelationship(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + relationshipCreateCfg, flags = cliutil.AddCommand(relationshipCmd, relationshipCreateCmd, qbclient.EnvPrefix) + + flags.String("child-table-id", "", "", "unique identifier (dbid) of the child table (required)") + flags.String("parent-table-id", "", "", "unique identifier (dbid) of the parent table (required)") + flags.String("foreign-key-label", "", "", "label for the foreign key field") + flags.String("lookup-fields", "", "", "ids of lookup fields in the parent table") +} + +func relationshipCreateCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if childTableID := globalCfg.DefaultTableID(); childTableID != "" { + relationshipCreateCfg.SetDefault("child-table-id", childTableID) + } + + // Use args[0] as value for the Child Table ID. + if len(args) > 0 { + relationshipCreateCfg.SetDefault("child-table-id", args[0]) + } + + // Use args[1] as value for the Parent Table ID. + if len(args) > 1 { + relationshipCreateCfg.SetDefault("parent-table-id", args[1]) + } + + return nil +} diff --git a/cmd/relationship_delete.go b/cmd/relationship_delete.go new file mode 100644 index 0000000..1adc778 --- /dev/null +++ b/cmd/relationship_delete.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var relationshipDeleteCfg *viper.Viper + +// relationshipDeleteCmd represents the app get command +var relationshipDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a relationship", + Args: relationshipDeleteCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.DeleteRelationshipInput{ + ChildTableID: relationshipDeleteCfg.GetString("child-table-id"), + RelationshipID: relationshipDeleteCfg.GetInt(qbclient.OptionRelationshipID), + } + + output, err := qb.DeleteRelationship(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + relationshipDeleteCfg, flags = cliutil.AddCommand(relationshipCmd, relationshipDeleteCmd, qbclient.EnvPrefix) + + flags.String("child-table-id", "", "", "unique identifier (dbid) of the child table (required)") + flags.Int(qbclient.OptionRelationshipID, "", 0, "unique identifier of the relationship (required)") +} + +func relationshipDeleteCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + relationshipDeleteCfg.SetDefault("child-table-id", tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + relationshipDeleteCfg.SetDefault("child-table-id", args[0]) + } + + // Use args[1] as value for the Relationship ID. + if len(args) > 1 { + relationshipDeleteCfg.SetDefault(qbclient.OptionRelationshipID, args[1]) + } + + return nil +} diff --git a/cmd/relationship_list.go b/cmd/relationship_list.go new file mode 100644 index 0000000..e5ebc42 --- /dev/null +++ b/cmd/relationship_list.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var relationshipListCfg *viper.Viper + +// relationshipListCmd represents the app get command +var relationshipListCmd = &cobra.Command{ + Use: "list", + Short: "List a table's relationships", + Args: relationshipListCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + output, err := qb.ListRelationshipsByTableID(relationshipListCfg.GetString(qbclient.OptionTableID)) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + relationshipListCfg, flags = cliutil.AddCommand(relationshipCmd, relationshipListCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) +} + +func relationshipListCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + relationshipListCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + relationshipListCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + return nil +} diff --git a/cmd/relationship_update.go b/cmd/relationship_update.go new file mode 100644 index 0000000..ba67721 --- /dev/null +++ b/cmd/relationship_update.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var relationshipUpdateCfg *viper.Viper + +// relationshipUpdateCmd represents the app get command +var relationshipUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update a relationship", + Args: relationshipUpdateCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.UpdateRelationshipInput{ + ChildTableID: relationshipUpdateCfg.GetString("child-table-id"), + RelationshipID: relationshipUpdateCfg.GetInt(qbclient.OptionRelationshipID), + } + + // Parse the list of fields IDs. + fids, err := qbcli.ParseFieldList(relationshipUpdateCfg.GetString("lookup-fields")) + cliutil.HandleError(cmd, err, "lookup-fields option invalid") + if len(fids) > 0 { + input.LookupFieldIDs = fids + } + + output, err := qb.UpdateRelationship(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + relationshipUpdateCfg, flags = cliutil.AddCommand(relationshipCmd, relationshipUpdateCmd, qbclient.EnvPrefix) + + flags.String("child-table-id", "", "", "unique identifier (dbid) of the child table (required)") + flags.Int(qbclient.OptionRelationshipID, "", 0, "unique identifier of the relationship (required)") + flags.String("lookup-fields", "", "", "ids of lookup fields in the parent table") +} + +func relationshipUpdateCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + relationshipUpdateCfg.SetDefault("child-table-id", tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + relationshipUpdateCfg.SetDefault("child-table-id", args[0]) + } + + // Use args[1] as value for the Relationship ID. + if len(args) > 1 { + relationshipUpdateCfg.SetDefault(qbclient.OptionRelationshipID, args[1]) + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..df10fba --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "os" + + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" +) + +var globalCfg qbcli.GlobalConfig + +var rootCmd = &cobra.Command{ + Use: "quickbase-cli", + Short: "A command line interface to Quick Base.", + Long: ``, + + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +// Execute runs the command line tool. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + cfg := cliutil.InitConfig(qbclient.EnvPrefix) + globalCfg = qbcli.NewGlobalConfig(rootCmd, cfg) +} diff --git a/cmd/table.go b/cmd/table.go new file mode 100644 index 0000000..a48ab2b --- /dev/null +++ b/cmd/table.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// tableCmd represents the table command +var tableCmd = &cobra.Command{ + Use: "table", + Short: "Tables resources", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + rootCmd.AddCommand(tableCmd) +} diff --git a/cmd/table_create.go b/cmd/table_create.go new file mode 100644 index 0000000..30febb3 --- /dev/null +++ b/cmd/table_create.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var tableCreateCfg *viper.Viper + +// tableCreateCmd represents the app get command +var tableCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a table", + Args: tableCreateCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.CreateTableInput{ + AppID: tableCreateCfg.GetString(qbclient.OptionAppID), + Name: tableCreateCfg.GetString("name"), + Description: tableCreateCfg.GetString("description"), + IconName: tableCreateCfg.GetString("icon-name"), + SingularNoun: tableCreateCfg.GetString("singular-noun"), + PluralNoun: tableCreateCfg.GetString("plural-noun"), + } + + output, err := qb.CreateTable(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + tableCreateCfg, flags = cliutil.AddCommand(tableCmd, tableCreateCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionAppID, "", "", qbcli.OptionAppIDDescription) + flags.String("name", "", "", "name for the table") + flags.String("description", "", "", "description for the table") + flags.String("icon-name", "", "", "icon for the table") + flags.String("singular-noun", "", "", "singular noun for records in the table") + flags.String("plural-noun", "", "", "plural noun for records in the table") +} + +func tableCreateCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default App ID if configured. + if appID := globalCfg.DefaultAppID(); appID != "" { + tableCreateCfg.SetDefault(qbclient.OptionAppID, appID) + } + + return nil +} diff --git a/cmd/table_delete.go b/cmd/table_delete.go new file mode 100644 index 0000000..bcd1182 --- /dev/null +++ b/cmd/table_delete.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var tableDeleteCfg *viper.Viper + +// tableDeleteCmd represents the app get command +var tableDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a table", + Args: tableDeleteCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.DeleteTableInput{ + AppID: tableDeleteCfg.GetString(qbclient.OptionAppID), + TableID: tableDeleteCfg.GetString(qbclient.OptionTableID), + } + + output, err := qb.DeleteTable(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + tableDeleteCfg, flags = cliutil.AddCommand(tableCmd, tableDeleteCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionAppID, "", "", qbcli.OptionAppIDDescription) + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) +} + +func tableDeleteCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default App ID if configured. + if appID := globalCfg.DefaultAppID(); appID != "" { + tableDeleteCfg.SetDefault(qbclient.OptionAppID, appID) + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + tableDeleteCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + tableDeleteCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + return nil +} diff --git a/cmd/table_get.go b/cmd/table_get.go new file mode 100644 index 0000000..95ce36f --- /dev/null +++ b/cmd/table_get.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var tableGetCfg *viper.Viper + +// tableGetCmd represents the app get command +var tableGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a table definition", + Args: tableGetCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + output, err := qb.GetTableByID(tableGetCfg.GetString(qbclient.OptionTableID)) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + tableGetCfg, flags = cliutil.AddCommand(tableCmd, tableGetCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) +} + +func tableGetCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + tableGetCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + tableGetCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + return nil +} diff --git a/cmd/table_list.go b/cmd/table_list.go new file mode 100644 index 0000000..e90fcde --- /dev/null +++ b/cmd/table_list.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var tableListCfg *viper.Viper + +// tableListCmd represents the app get command +var tableListCmd = &cobra.Command{ + Use: "list", + Short: "List tables in an app", + Args: tableListCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + output, err := qb.ListTablesByAppID(tableListCfg.GetString(qbclient.OptionAppID)) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + tableListCfg, flags = cliutil.AddCommand(tableCmd, tableListCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionAppID, "", "", qbcli.OptionAppIDDescription) +} + +func tableListCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default App ID if configured. + if appID := globalCfg.DefaultAppID(); appID != "" { + tableListCfg.SetDefault(qbclient.OptionAppID, appID) + } + + return nil +} diff --git a/cmd/table_open.go b/cmd/table_open.go new file mode 100644 index 0000000..86bf9ab --- /dev/null +++ b/cmd/table_open.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + "net/url" + + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/pkg/browser" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var tableOpenCfg *viper.Viper + +// tableOpenCmd represents the app get command +var tableOpenCmd = &cobra.Command{ + Use: "open", + Short: "Open a table's page in a browser", + Args: tableOpenCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, _ := qbcli.NewLogger(cmd, globalCfg) + tableID := tableOpenCfg.GetString(qbclient.OptionTableID) + + var a string + if !tableOpenCfg.GetBool("settings") { + a = "td" + } else { + a = "TableSettingsHome" + } + + u := fmt.Sprintf("https://%s/db/%s?a=%s", globalCfg.RealmHostname(), url.PathEscape(tableID), a) + err := browser.OpenURL(u) + logger.FatalIfError(ctx, "error opening table in browser", err) + }, +} + +func init() { + var flags *cliutil.Flagger + tableOpenCfg, flags = cliutil.AddCommand(tableCmd, tableOpenCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) + flags.Bool("settings", "", false, "open the settings page") +} + +func tableOpenCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default Table ID if configured. + if tableID := globalCfg.DefaultTableID(); tableID != "" { + tableOpenCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + tableOpenCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + return nil +} diff --git a/cmd/table_update.go b/cmd/table_update.go new file mode 100644 index 0000000..081968c --- /dev/null +++ b/cmd/table_update.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var tableUpdateCfg *viper.Viper + +// tableUpdateCmd represents the app get command +var tableUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update a table", + Args: tableUpdateCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.UpdateTableInput{ + AppID: tableUpdateCfg.GetString(qbclient.OptionAppID), + TableID: tableUpdateCfg.GetString(qbclient.OptionTableID), + Name: tableUpdateCfg.GetString("name"), + Description: tableUpdateCfg.GetString("description"), + IconName: tableUpdateCfg.GetString("icon-name"), + SingularNoun: tableUpdateCfg.GetString("singular-noun"), + PluralNoun: tableUpdateCfg.GetString("plural-noun"), + } + + output, err := qb.UpdateTable(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + tableUpdateCfg, flags = cliutil.AddCommand(tableCmd, tableUpdateCmd, qbclient.EnvPrefix) + + flags.String(qbclient.OptionAppID, "", "", qbcli.OptionAppIDDescription) + flags.String(qbclient.OptionTableID, "", "", qbcli.OptionTableIDDescription) + flags.String("name", "", "", "name for the table") + flags.String("description", "", "", "description for the table") + flags.String("icon-name", "", "", "icon for the table") + flags.String("singular-noun", "", "", "singular noun for records in the table") + flags.String("plural-noun", "", "", "plural noun for records in the table") +} + +func tableUpdateCmdValidate(cmd *cobra.Command, args []string) error { + if err := globalCfg.Validate(); err != nil { + return err + } + + // Set the default App ID and Table ID if configured. + if appID := globalCfg.DefaultAppID(); appID != "" { + tableUpdateCfg.SetDefault(qbclient.OptionAppID, appID) + } + if tableID := globalCfg.DefaultTableID(); tableID != "" { + tableUpdateCfg.SetDefault(qbclient.OptionTableID, tableID) + } + + // Use args[0] as value for the Table ID. + if len(args) > 0 { + tableUpdateCfg.SetDefault(qbclient.OptionTableID, args[0]) + } + + return nil +} diff --git a/cmd/variable.go b/cmd/variable.go new file mode 100644 index 0000000..f09086b --- /dev/null +++ b/cmd/variable.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// variableCmd represents the variable command +var variableCmd = &cobra.Command{ + Use: "variable", + Aliases: []string{"var"}, + Short: "Variable resources", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + rootCmd.AddCommand(variableCmd) +} diff --git a/cmd/variable_get.go b/cmd/variable_get.go new file mode 100644 index 0000000..074d990 --- /dev/null +++ b/cmd/variable_get.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var variableGetCfg *viper.Viper + +// variableGetCmd represents the app get command +var variableGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a variable", + Args: variableGetCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.GetVariableInput{} + qbcli.GetOptions(ctx, logger, input, variableGetCfg) + + output, err := qb.GetVariable(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + variableGetCfg, flags = cliutil.AddCommand(variableCmd, variableGetCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.GetVariableInput{}) +} + +func variableGetCmdValidate(cmd *cobra.Command, args []string) (err error) { + if err = globalCfg.Validate(); err == nil { + globalCfg.SetDefaultAppID(variableGetCfg) + qbcli.SetOptionFromArg(variableGetCfg, args, 0, "name") + } + return +} diff --git a/cmd/variable_set.go b/cmd/variable_set.go new file mode 100644 index 0000000..faaed23 --- /dev/null +++ b/cmd/variable_set.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/QuickBase/quickbase-cli/qbcli" + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var variableSetCfg *viper.Viper + +// variableSetCmd represents the app get command +var variableSetCmd = &cobra.Command{ + Use: "set", + Short: "Set a variable", + Args: variableSetCmdValidate, + + Run: func(cmd *cobra.Command, args []string) { + ctx, logger, qb := qbcli.NewClient(cmd, globalCfg) + + input := &qbclient.SetVariableInput{} + qbcli.GetOptions(ctx, logger, input, variableSetCfg) + + output, err := qb.SetVariable(input) + qbcli.Render(ctx, logger, cmd, globalCfg, output, err) + }, +} + +func init() { + var flags *cliutil.Flagger + variableSetCfg, flags = cliutil.AddCommand(variableCmd, variableSetCmd, qbclient.EnvPrefix) + flags.SetOptions(&qbclient.SetVariableInput{}) +} + +func variableSetCmdValidate(cmd *cobra.Command, args []string) (err error) { + if err = globalCfg.Validate(); err == nil { + globalCfg.SetDefaultAppID(variableSetCfg) + qbcli.SetOptionFromArg(variableSetCfg, args, 0, "name") + qbcli.SetOptionFromArg(variableSetCfg, args, 1, "value") + } + return +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ffd7302 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/QuickBase/quickbase-cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/qbcli/client.go b/qbcli/client.go new file mode 100644 index 0000000..b788dc2 --- /dev/null +++ b/qbcli/client.go @@ -0,0 +1,63 @@ +package qbcli + +import ( + "context" + "os" + + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/rs/xid" + "github.com/spf13/cobra" +) + +// NewLogger returns a new *cliutil.LeveledLogger. +func NewLogger(cmd *cobra.Command, cfg GlobalConfig) (ctx context.Context, logger *cliutil.LeveledLogger, transid xid.ID) { + ctx, logger, transid = cliutil.NewLoggerWithContext(context.Background(), cfg.LogLevel()) + logger.SetOutput(os.Stderr) + + // Open the log file and set the logger to write to it. + if logFile := cfg.LogFile(); logFile != "" { + file, err := os.OpenFile(logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) + logger.ErrorIfError(ctx, "error opening log file", err) + logger.SetOutput(file) + } + + return +} + +// NewClient returns a new *qbclient.Client. +func NewClient(cmd *cobra.Command, cfg GlobalConfig) (ctx context.Context, logger *cliutil.LeveledLogger, qb *qbclient.Client) { + var transid xid.ID + ctx, logger, transid = NewLogger(cmd, cfg) + + // Instantiate the Quick Base API client with the logger plugin. + qb = qbclient.New(cfg) + qb.AddPlugin(NewLoggerPlugin(ctx, logger)) + + // Dump raw requests and responses to the dump directory. + if dumpDir := cfg.DumpDirectory(); dumpDir != "" { + qb.AddPlugin(NewDumpPlugin(ctx, logger, transid.String(), dumpDir)) + } + + return +} + +// GetFieldTypeMap returns a mapping of field ID to Quick Base Field type for +// the fields in a table. +// +// TODO Caching? +// +// TODO we can detect the various types. through properties. +func GetFieldTypeMap(qb *qbclient.Client, tableID string) (tmap map[int]string, err error) { + output, err := qb.ListFieldsByTableID(tableID) + if err != nil { + return + } + + tmap = make(map[int]string, len(output.Fields)) + for _, field := range output.Fields { + tmap[field.FieldID] = field.Type + } + + return +} diff --git a/qbcli/config.go b/qbcli/config.go new file mode 100644 index 0000000..b9e63d6 --- /dev/null +++ b/qbcli/config.go @@ -0,0 +1,143 @@ +package qbcli + +import ( + "context" + "errors" + "fmt" + + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/go-playground/validator" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Option* constants contain CLI options. +const ( + OptionDumpDirectory = "dump-dir" + OptionJMESPathFilter = "filter" + OptionLogFile = "log-file" + OptionLogLevel = "log-level" + OptionQuiet = "quiet" +) + +// Option*Description constants contain common option descriptions. +const ( + OptionAppIDDescription = "unique identifier of an app (required)" + OptionFieldIDDescription = "unique identifier (fid) of the field (required)" + OptionParentTableIDDescription = "unique identifier (dbid) of the parent table (required)" + OptionTableIDDescription = "unique identifier (dbid) of the table (required)" + OptionQuietDescription = "suppress output written to stdout" +) + +// NewGlobalConfig returns a GlobalConfig. +func NewGlobalConfig(cmd *cobra.Command, cfg *viper.Viper) GlobalConfig { + flags := cliutil.NewFlagger(cmd, cfg) + + flags.PersistentString(OptionDumpDirectory, "d", "", "directory for files that request/response are dumped to for debugging") + flags.PersistentString(OptionJMESPathFilter, "F", "", "JMESPath filter applied to output") + flags.PersistentString(OptionLogFile, "f", "", "file log messages are written to") + flags.PersistentString(OptionLogLevel, "l", cliutil.LogError, "minimum log level") + flags.PersistentString(qbclient.OptionProfile, "p", "default", "configuration profile") + flags.PersistentBool(OptionQuiet, "q", false, OptionQuietDescription) + flags.PersistentString(qbclient.OptionRealmHostname, "r", "", "realm hostname, e.g., example.quickbase.com") + flags.PersistentString(qbclient.OptionTemporaryToken, "t", "", "temporary token used to authenticate API requests") + flags.PersistentString(qbclient.OptionUserToken, "u", "", "user token used to authenticate API requests") + + return GlobalConfig{cfg: cfg} +} + +// GlobalConfig contains configuration common to all commands. +type GlobalConfig struct { + cfg *viper.Viper +} + +// ConfigDir returns the configuration directory. +func (c GlobalConfig) ConfigDir() string { return c.cfg.GetString(qbclient.OptionConfigDir) } + +// DefaultAppID returns the default app ID. +func (c GlobalConfig) DefaultAppID() string { return c.cfg.GetString(qbclient.OptionAppID) } + +// DefaultFieldID returns the default field ID. +func (c GlobalConfig) DefaultFieldID() int { return c.cfg.GetInt(qbclient.OptionFieldID) } + +// DefaultTableID returns the default table ID. +func (c GlobalConfig) DefaultTableID() string { return c.cfg.GetString(qbclient.OptionTableID) } + +// DumpDirectory returns the configured dump file directory. +func (c GlobalConfig) DumpDirectory() string { return c.cfg.GetString(OptionDumpDirectory) } + +// JMESPathFilter returns the JMESPath filter. +func (c GlobalConfig) JMESPathFilter() string { return c.cfg.GetString(OptionJMESPathFilter) } + +// LogFile returns the configured log file. +func (c GlobalConfig) LogFile() string { return c.cfg.GetString(OptionLogFile) } + +// LogLevel returns the configured log level. +func (c GlobalConfig) LogLevel() string { return c.cfg.GetString(OptionLogLevel) } + +// Profile returns the configured profile. +func (c GlobalConfig) Profile() string { return c.cfg.GetString(qbclient.OptionProfile) } + +// Quiet returns whehter to suppress output written to stdout. +func (c GlobalConfig) Quiet() bool { return c.cfg.GetBool(OptionQuiet) } + +// RealmHostname returns the configured realm hostname. +func (c GlobalConfig) RealmHostname() string { return c.cfg.GetString(qbclient.OptionRealmHostname) } + +// TemporaryToken returns the configured log level. +func (c GlobalConfig) TemporaryToken() string { return c.cfg.GetString(qbclient.OptionTemporaryToken) } + +// UserToken returns the configured log level. +func (c GlobalConfig) UserToken() string { return c.cfg.GetString(qbclient.OptionUserToken) } + +// ReadInConfig reads in the config file. +func (c *GlobalConfig) ReadInConfig() error { return qbclient.ReadInConfig(c.cfg) } + +// Validate reads the configuration file and validates the global configuration +// options. +func (c *GlobalConfig) Validate() error { + if !cliutil.LogLevelValid(c.LogLevel()) { + return fmt.Errorf("value %q for option %q: %w", c.LogLevel(), OptionLogLevel, errors.New("invalid value")) + } + + if err := c.ReadInConfig(); err != nil { + return err + } + + if c.RealmHostname() == "" { + return fmt.Errorf("option %q: %w", qbclient.OptionRealmHostname, errors.New("value required")) + } + + return nil +} + +// SetDefaultAppID sets the default app in the command's configuration. +func (c GlobalConfig) SetDefaultAppID(cfg *viper.Viper) { + if appID := c.DefaultAppID(); appID != "" { + cfg.SetDefault(qbclient.OptionAppID, appID) + } +} + +// SetDefaultTableID sets the default table in the command's configuration. +func (c GlobalConfig) SetDefaultTableID(cfg *viper.Viper) { + if tableID := c.DefaultTableID(); tableID != "" { + cfg.SetDefault(qbclient.OptionTableID, tableID) + } +} + +// SetOptionFromArg sets an option from an argument. +func SetOptionFromArg(cfg *viper.Viper, args []string, idx int, option string) { + if len(args) > idx { + cfg.SetDefault(option, args[idx]) + } +} + +// GetOptions gets options based on the input and validates them. +func GetOptions(ctx context.Context, logger *cliutil.LeveledLogger, input interface{}, cfg *viper.Viper) { + err := cliutil.GetOptions(input, cfg) + logger.FatalIfError(ctx, "error getting options", err) + + err = validator.New().Struct(input) + HandleError(ctx, logger, "input not valid", err) +} diff --git a/qbcli/error.go b/qbcli/error.go new file mode 100644 index 0000000..fb19651 --- /dev/null +++ b/qbcli/error.go @@ -0,0 +1,16 @@ +package qbcli + +import ( + "context" + "os" + + "github.com/cpliakas/cliutil" +) + +// HandleError handles an error by logging it and returning a non-zero status. +func HandleError(ctx context.Context, logger *cliutil.LeveledLogger, message string, err error) { + if err != nil { + logger.Error(ctx, message, err) + os.Exit(1) + } +} diff --git a/qbcli/field.go b/qbcli/field.go new file mode 100644 index 0000000..abe4c6e --- /dev/null +++ b/qbcli/field.go @@ -0,0 +1,131 @@ +package qbcli + +import ( + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" + "github.com/spf13/viper" +) + +// FieldOption* constants contain common field option names. +const ( + FieldOptionAddToForms = "add-to-forms" + FieldOptionAddToNewReports = "add-to-new-reports" + FieldOptionAllowNewChoices = "allow-new-choices" + FieldOptionAutoFill = "auto-fill" + FieldOptionDefaultValue = "default-value" + FieldOptionDisplayInBold = "bold" + FieldOptionDisplayWithoutWrapping = "no-wrap" + FieldOptionExactMatch = "exact-match" + FieldOptionFieldHelpText = "help-text" + FieldOptionForeignKey = "foreign-key" + FieldOptionFormula = "formula" + FieldOptionLabel = "label" + FieldOptionMaxCharacters = "max-chars" + FieldOptionNumberOfLines = "num-lines" + FieldOptionRequired = "required" + FieldOptionParentTable = "parent-table" + FieldOptionPrimaryKey = "primary-key" + FieldOptionRelatedField = "related-field" + FieldOptionSearchable = "searchable" + FieldOptionSortChoicesAsGiven = "sort-as-given" + FieldOptionTrackField = "track" + FieldOptionType = "type" + FieldOptionUnique = "unique" + FieldOptionWidthOfInputBox = "width" +) + +// SetFieldOptions sets common field options. +func SetFieldOptions(flags *cliutil.Flagger) { + + // Basics + flags.String(FieldOptionLabel, "", "", "") + flags.String(FieldOptionType, "", "", "") + flags.Bool(FieldOptionRequired, "", false, "") + flags.Bool(FieldOptionUnique, "", false, "") + + // Display + flags.Bool(FieldOptionDisplayInBold, "", false, "") + flags.Bool(FieldOptionDisplayWithoutWrapping, "", false, "") + + // Advanced + flags.Bool(FieldOptionAutoFill, "", false, "") + flags.Bool(FieldOptionSearchable, "", false, "") + flags.Bool(FieldOptionAddToNewReports, "", false, "") + flags.String(FieldOptionFieldHelpText, "", "", "") + flags.Bool(FieldOptionTrackField, "", false, "") + + // No UI + flags.Bool(FieldOptionAddToForms, "", false, "") + + // Properties + + // Basics + flags.String(FieldOptionDefaultValue, "", "", "") + + // Text - Multiple Choice field options + flags.Bool(FieldOptionAllowNewChoices, "", false, "") + flags.Bool(FieldOptionSortChoicesAsGiven, "", false, "") + + // Display + flags.Int(FieldOptionNumberOfLines, "", 0, "") + flags.Int(FieldOptionMaxCharacters, "", 0, "") + flags.Int(FieldOptionWidthOfInputBox, "", 0, "") + + // No UI + flags.Bool(FieldOptionExactMatch, "", false, "") + flags.Bool(FieldOptionForeignKey, "", false, "") + flags.String(FieldOptionFormula, "", "", "") + flags.String(FieldOptionParentTable, "", "", "") + flags.Bool(FieldOptionPrimaryKey, "", false, "") + flags.Int(FieldOptionRelatedField, "", 0, "") +} + +// NewFieldFromOptions returns a new qbclient.Field from values in cfg. +func NewFieldFromOptions(cfg *viper.Viper) (f qbclient.Field) { + + // Basics + f.Type = cfg.GetString(FieldOptionType) + f.Label = cfg.GetString(FieldOptionLabel) + cliutil.SetBoolValue(cfg, FieldOptionRequired, &f.Required) + cliutil.SetBoolValue(cfg, FieldOptionUnique, &f.Unique) + + // Display + cliutil.SetBoolValue(cfg, FieldOptionDisplayInBold, &f.DisplayInBold) + cliutil.SetBoolValue(cfg, FieldOptionDisplayWithoutWrapping, &f.DisplayWithoutWrapping) + + // Advanced + cliutil.SetBoolValue(cfg, FieldOptionAutoFill, &f.AutoFill) + cliutil.SetBoolValue(cfg, FieldOptionSearchable, &f.Searchable) + cliutil.SetBoolValue(cfg, FieldOptionAddToNewReports, &f.AddToNewReports) + cliutil.SetStringValue(cfg, FieldOptionFieldHelpText, &f.FieldHelpText) + cliutil.SetBoolValue(cfg, FieldOptionTrackField, &f.TrackField) + + return +} + +// NewPropertiesFromOptions returns a new qbclient.Properties from values in cfg. +// See https://developer.quickbase.com/operation/createField +func NewPropertiesFromOptions(cfg *viper.Viper) (p qbclient.FieldProperties) { + + // Basics + cliutil.SetStringValue(cfg, FieldOptionDefaultValue, &p.DefaultValue) + + // Text - Multiple Choice field options + cliutil.SetBoolValue(cfg, FieldOptionAllowNewChoices, &p.AllowNewChoices) + cliutil.SetBoolValue(cfg, FieldOptionSortChoicesAsGiven, &p.SortChoicesAsGiven) + + // Display + cliutil.SetIntValue(cfg, FieldOptionNumberOfLines, &p.NumberOfLines) + cliutil.SetIntValue(cfg, FieldOptionMaxCharacters, &p.MaxCharacters) + cliutil.SetIntValue(cfg, FieldOptionWidthOfInputBox, &p.WidthOfInputBox) + + // No UI + cliutil.SetBoolValue(cfg, FieldOptionExactMatch, &p.ExactMatch) + cliutil.SetBoolValue(cfg, FieldOptionForeignKey, &p.ForeignKey) + cliutil.SetStringValue(cfg, FieldOptionFormula, &p.Formula) + cliutil.SetStringValue(cfg, FieldOptionParentTable, &p.ParentTable) + cliutil.SetBoolValue(cfg, FieldOptionPrimaryKey, &p.PrimaryKey) + cliutil.SetIntValue(cfg, FieldOptionRelatedField, &p.RelatedField) + + return +} diff --git a/qbcli/input.go b/qbcli/input.go new file mode 100644 index 0000000..05206d6 --- /dev/null +++ b/qbcli/input.go @@ -0,0 +1,38 @@ +package qbcli + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/QuickBase/quickbase-cli/qbclient" +) + +// Prompt prompts a user for input and returns what they typed. +func Prompt(label string, validate qbclient.ValidateStringFn) (s string, err error) { + reader := bufio.NewReader(os.Stdin) + + for { + // Print the prompt label. + fmt.Print(label) + + // Read the input. + s, err = reader.ReadString('\n') + if err != nil { + return + } + + // Trim spaces, inclusive of the newline. + s = strings.TrimSpace(s) + + // Validate the input. + if verr := validate(s); verr != nil { + fmt.Printf("%s\n\n", verr) + } else { + break + } + } + + return +} diff --git a/qbcli/parse.go b/qbcli/parse.go new file mode 100644 index 0000000..e1b6207 --- /dev/null +++ b/qbcli/parse.go @@ -0,0 +1,135 @@ +package qbcli + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" +) + +var reSortBy, reGroupBy *regexp.Regexp + +// ParseFieldList parses a list of integers from a string. +func ParseFieldList(s string) (fids []int, err error) { + if s == "" { + return + } + + parts := strings.Split(s, ",") + fids = make([]int, len(parts)) + + for i, part := range parts { + part = strings.TrimSpace(part) + if !cliutil.IsNumber(part) { + err = errors.New("expecting fid to be a number") + return + } + fid, _ := strconv.Atoi(part) + fids[i] = fid + } + + return +} + +// ParseQuery parses queries. It also detcts and transforms simple queries into +// Quick Base query syntax. +func ParseQuery(q string) string { + + // Returns as-is if using Quick Base query syntax. + if strings.HasPrefix(q, "{") || strings.HasPrefix(q, "(") { + return q + } + + // Parse the key/value pairs. + m := cliutil.ParseKeyValue(q) + clauses := make([]string, len(m)) + + // Convert simple syntax into Quick Base query syntax. + i := 0 + for k, v := range m { + if v == "" { + clauses[i] = fmt.Sprintf("{3.EX.%q}", k) + } else { + clauses[i] = fmt.Sprintf("{%q.EX.%q}", k, v) + } + i++ + } + + // Join all clauses by AND. + return strings.Join(clauses, " AND ") +} + +// ParseSortBy parses the sortBy clause. +func ParseSortBy(s string) (sortBy []*qbclient.QueryRecordsInputSortBy, err error) { + clauses := strings.Split(s, ",") + sortBy = make([]*qbclient.QueryRecordsInputSortBy, len(clauses)) + + for i, clause := range clauses { + matches := reSortBy.FindAllStringSubmatch(clause, -1) + + // Return an error if any clause cannot be parsed. + if len(matches) == 0 { + err = errors.New("no match") + return + } + + // Convert the field ID to an integer. Panic on any errors, because the + // regex should only parse integers. Signifies logic error in app. + fid, err := strconv.Atoi(matches[0][1]) + if err != nil { + panic(err) + } + + // Default to ASC. + order := matches[0][2] + if order == "" { + order = qbclient.SortByASC + } + + sortBy[i] = &qbclient.QueryRecordsInputSortBy{ + FieldID: fid, + Order: order, + } + } + + return +} + +// ParseGroupBy parses the groupBy clause. +func ParseGroupBy(s string) (groupBy []*qbclient.QueryRecordsInputGroupBy, err error) { + clauses := strings.Split(s, ",") + groupBy = make([]*qbclient.QueryRecordsInputGroupBy, len(clauses)) + + for i, clause := range clauses { + matches := reGroupBy.FindAllStringSubmatch(clause, -1) + + // Return an error if any clause cannot be parsed. + if len(matches) == 0 { + err = errors.New("no match") + return + } + + // Convert the field ID to an integer. Panic on any errors, because the + // regex should only parse integers. Signifies logic error in app. + fid, err := strconv.Atoi(matches[0][1]) + if err != nil { + panic(err) + } + + groupBy[i] = &qbclient.QueryRecordsInputGroupBy{ + FieldID: fid, + Grouping: matches[0][2], + } + } + + return +} + +func init() { + reSortBy = regexp.MustCompile(`^\s*(\d+)(?:\s+(ASC|DESC)?\s*)?$`) + reGroupBy = regexp.MustCompile(`^\s*(\d+)(?:\s+([-A-Za-z0-9_]+)?\s*)?$`) +} diff --git a/qbcli/plugin.go b/qbcli/plugin.go new file mode 100644 index 0000000..172a14d --- /dev/null +++ b/qbcli/plugin.go @@ -0,0 +1,165 @@ +package qbcli + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httputil" + "os" + "strconv" + "strings" + "time" + + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/cpliakas/cliutil" +) + +// LoggerPlugin implements qbclient.Plugin and logs requests. +type LoggerPlugin struct { + ctx context.Context + logger *cliutil.LeveledLogger +} + +// NewLoggerPlugin returns a LoggerPlugin, which implements qbclient.Plugin. +func NewLoggerPlugin(ctx context.Context, logger *cliutil.LeveledLogger) qbclient.Plugin { + return LoggerPlugin{ctx: ctx, logger: logger} +} + +// PreRequest implements qbclient.Plugin.PreRequest. +func (p LoggerPlugin) PreRequest(req *http.Request) { + ctx := p.ctx + ctx = cliutil.ContextWithLogTag(ctx, "method", req.Method) + ctx = cliutil.ContextWithLogTag(ctx, "url", req.URL.String()) + p.logger.Debug(ctx, "api request constructed") +} + +// PostResponse implements qbclient.Plugin.PostResponse. +func (p LoggerPlugin) PostResponse(resp *http.Response) { + if resp != nil { + ctx := p.ctx + ctx = cliutil.ContextWithLogTag(ctx, "method", resp.Request.Method) + ctx = cliutil.ContextWithLogTag(ctx, "url", resp.Request.URL.String()) + ctx = cliutil.ContextWithLogTag(ctx, "status", resp.Status) + p.logger.Info(ctx, "api response returned") + } +} + +// DumpPlugin implements qbclient.Plugin and dumps requests and responses to +// files in a directory. +type DumpPlugin struct { + ctx context.Context + directory string + logger *cliutil.LeveledLogger + transid string +} + +// NewDumpPlugin returns a DumpPlugin, which implements qbclient.Plugin. +func NewDumpPlugin(ctx context.Context, logger *cliutil.LeveledLogger, transid string, directory string) qbclient.Plugin { + dir := strings.TrimRight(directory, string(os.PathSeparator)) + return DumpPlugin{ctx: ctx, logger: logger, transid: transid, directory: dir} +} + +// PreRequest implements qbclient.Plugin.PreRequest. +func (p DumpPlugin) PreRequest(req *http.Request) { + ctx, file, err := p.openDumpFile("request") + if err != nil { + return + } + defer file.Close() + + // Dump the request headers. + headers, err := httputil.DumpRequestOut(req, false) + if err != nil { + p.logger.Error(ctx, "error dumping request headers", err) + return + } + + // Read the request body. + defer req.Body.Close() + body, err := ioutil.ReadAll(req.Body) + if err != nil { + p.logger.Error(ctx, "error reading request body", err) + return + } + + // Build the request sent over the wire. + buf := bytes.NewBuffer([]byte(``)) + buf.Write(headers) + buf.Write(body) + + // Mask any user tokens. + dump := qbclient.MaskUserToken(buf.Bytes()) + + // Write the request to the dump file, and log the result. + n, err := file.Write(dump) + ctx = cliutil.ContextWithLogTag(ctx, "bytes", strconv.Itoa(n)) + if err == nil { + p.logger.Debug(ctx, "wrote request to dump file") + } else { + p.logger.Error(ctx, "error writing request to dump file", err) + } + + // Put the request body back so we can read it again. + req.Body = ioutil.NopCloser(bytes.NewBuffer(body)) +} + +// PostResponse implements qbclient.Plugin.PostResponse. +func (p DumpPlugin) PostResponse(resp *http.Response) { + ctx, file, err := p.openDumpFile("response") + if err != nil { + return + } + defer file.Close() + + // Dump the response headers. + headers, err := httputil.DumpResponse(resp, false) + if err != nil { + p.logger.Error(ctx, "error dumping response headers", err) + return + } + + // Read the response body. + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + p.logger.Error(ctx, "error reading response body", err) + return + } + + // Build the response returned over the wire. + buf := bytes.NewBuffer([]byte(``)) + buf.Write(headers) + buf.Write(body) + + // Mask any user tokens. + dump := qbclient.MaskUserToken(buf.Bytes()) + + // Write the response to the dump file, and log the result. + n, err := file.Write(dump) + ctx = cliutil.ContextWithLogTag(ctx, "bytes", strconv.Itoa(n)) + if err == nil { + p.logger.Debug(ctx, "wrote response to dump file") + } else { + p.logger.Error(ctx, "error writing response to dump file", err) + } + + // Put the response body back so we can read it again. + resp.Body = ioutil.NopCloser(bytes.NewBuffer(body)) +} + +func (p DumpPlugin) openDumpFile(typ string) (ctx context.Context, file *os.File, err error) { + filename := fmt.Sprintf("%v-%s-%s.txt", time.Now().Unix(), p.transid, typ) + filepath := qbclient.Filepath(p.directory, filename) + ctx = cliutil.ContextWithLogTag(p.ctx, "file", filepath) + + // Open the file, log the result. + if file, err = os.OpenFile(filepath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644); err == nil { + p.logger.Debug(ctx, fmt.Sprintf("created dump file for %s", typ)) + } else { + p.logger.Error(ctx, fmt.Sprintf("error creating dump file for %s", typ), err) + } + + return +} diff --git a/qbcli/render.go b/qbcli/render.go new file mode 100644 index 0000000..939aba8 --- /dev/null +++ b/qbcli/render.go @@ -0,0 +1,34 @@ +package qbcli + +import ( + "context" + "errors" + "fmt" + + "github.com/QuickBase/quickbase-cli/qberrors" + "github.com/cpliakas/cliutil" + "github.com/spf13/cobra" +) + +// Render renders the output in JSON, or writes an error log. +func Render( + ctx context.Context, + logger *cliutil.LeveledLogger, + cmd *cobra.Command, + cfg GlobalConfig, + v interface{}, + err error, +) { + + // Render the error. + if err != nil { + ctx = cliutil.ContextWithLogTag(ctx, "code", fmt.Sprintf("%v", qberrors.StatusCode(err))) + HandleError(ctx, logger, qberrors.SafeMessage(err), errors.New(qberrors.SafeDetail(err))) + } + + // Render the output. + if !cfg.Quiet() { + err := cliutil.PrintJSONWithFilter(v, cfg.JMESPathFilter()) + HandleError(ctx, logger, "invalid JMESPath filter", err) + } +} diff --git a/qbclient/actions_apps.go b/qbclient/actions_apps.go new file mode 100644 index 0000000..34aa650 --- /dev/null +++ b/qbclient/actions_apps.go @@ -0,0 +1,55 @@ +package qbclient + +import ( + "io" + "net/http" +) + +// ListAppsInput models the XML API request sent to API_GrantedDBs. +// See https://help.quickbase.com/api-guide/granteddbs.html +type ListAppsInput struct { + XMLRequestParameters + XMLCredentialParameters + + c *Client + u string + + AdminOnly bool `xml:"adminOnly,omitempty" cliutil:"option=admin-only"` + ExcludeParents bool `xml:"excludeparents,int,omitempty" cliutil:"option=exclude-parents"` + IncludeAncestors bool `xml:"includeancestors,int,omitempty" cliutil:"option=include-ancestors"` + RealmAppsOnly bool `xml:"realmAppsOnly,omitempty" cliutil:"option=realm-apps-only"` + WithEmbeddedTables bool `xml:"withembeddedtables,int" cliutil:"option=with-embedded-tables"` +} + +func (i *ListAppsInput) method() string { return http.MethodPost } +func (i *ListAppsInput) url() string { return i.u } +func (i *ListAppsInput) addHeaders(req *http.Request) { addHeadersXML(req, i.c, "API_GrantedDBs") } +func (i *ListAppsInput) encode() ([]byte, error) { return marshalXML(i, i.c) } + +// ListAppsOutput models the XML API response returned by API_GrantedDBs. +// See https://help.quickbase.com/api-guide/granteddbs.html +type ListAppsOutput struct { + XMLResponseParameters + + Databases []*ListAppsOutputDatabases `xml:"databases>dbinfo" json:"apps,omitempty"` +} + +// ListAppsOutputDatabases modesl the databases propertie. +type ListAppsOutputDatabases struct { + AncestorAppID string `xml:"ancestorappid,omitempty" json:"ancestorAppId,omitempty"` + ID string `xml:"dbid" json:"appId"` + Name string `xml:"dbname" json:"name"` + OldestAncestorAppID string `xml:"oldestancestorappid,omitempty" json:"oldAncestorAppId,omitempty"` +} + +func (o *ListAppsOutput) decode(body io.ReadCloser) error { return unmarshalXML(body, o) } + +// ListApps sends an XML API request to API_GrantedDBs. +// See https://help.quickbase.com/api-guide/granteddbs.html +func (c *Client) ListApps(input *ListAppsInput) (output *ListAppsOutput, err error) { + input.c = c + input.u = "https://" + c.ReamlHostname + "/db/main" + output = &ListAppsOutput{} + err = c.Do(input, output) + return +} diff --git a/qbclient/actions_variable.go b/qbclient/actions_variable.go new file mode 100644 index 0000000..c382815 --- /dev/null +++ b/qbclient/actions_variable.go @@ -0,0 +1,95 @@ +package qbclient + +import ( + "io" + "net/http" + "net/url" +) + +// GetVariableInput models the XML API request sent to API_GetDBvar +// See https://help.quickbase.com/api-guide/index.html#getdbvar.html +type GetVariableInput struct { + XMLRequestParameters + XMLCredentialParameters + + c *Client + u string + + AppID string `xml:"-" validate:"required" cliutil:"option=app-id"` + Name string `xml:"varname" validate:"required" cliutil:"option=name"` +} + +func (i *GetVariableInput) method() string { return http.MethodPost } +func (i *GetVariableInput) url() string { return i.u } +func (i *GetVariableInput) addHeaders(req *http.Request) { addHeadersXML(req, i.c, "API_GetDBvar") } +func (i *GetVariableInput) encode() ([]byte, error) { return marshalXML(i, i.c) } + +// GetVariableOutput models the XML API response returned by API_GetDBvar. +// See https://help.quickbase.com/api-guide/index.html#getdbvar.html +type GetVariableOutput struct { + XMLResponseParameters + Variable +} + +func (o *GetVariableOutput) decode(body io.ReadCloser) error { return unmarshalXML(body, o) } + +// GetVariable sends an XML API request to API_GetDBvar. +// See https://help.quickbase.com/api-guide/index.html#getdbvar.html +func (c *Client) GetVariable(input *GetVariableInput) (output *GetVariableOutput, err error) { + input.c = c + input.u = "https://" + url.PathEscape(c.ReamlHostname) + "/db/" + url.PathEscape(input.AppID) + + output = &GetVariableOutput{} + err = c.Do(input, output) + if err == nil { + output.Name = input.Name + } + + return +} + +// SetVariableInput models a request sent to API_SetDBvar via the XML API. +// See https://help.quickbase.com/api-guide/index.html#setdbvar.html +type SetVariableInput struct { + XMLRequestParameters + XMLCredentialParameters + + c *Client + u string + + AppID string `xml:"-" validate:"required" cliutil:"option=app-id"` + Name string `xml:"varname" validate:"required" cliutil:"option=name"` + Value string `xml:"value" cliutil:"option=value"` +} + +func (i *SetVariableInput) method() string { return http.MethodPost } +func (i *SetVariableInput) url() string { return i.u } +func (i *SetVariableInput) addHeaders(req *http.Request) { addHeadersXML(req, i.c, "API_SetDBvar") } +func (i *SetVariableInput) encode() ([]byte, error) { return marshalXML(i, i.c) } + +// SetVariableOutput models the XML API response returned by API_SetDBvar +// See https://help.quickbase.com/api-guide/index.html#setdbvar.html +type SetVariableOutput struct { + XMLResponseParameters + + Name string `xml:"-" json:"name,omitempty"` + Value string `xml:"-" json:"value,omitempty"` +} + +func (o *SetVariableOutput) decode(body io.ReadCloser) error { return unmarshalXML(body, o) } + +// SetVariable sends an XML API request to API_SetDBvar. +// See https://help.quickbase.com/api-guide/index.html#setdbvar.html +func (c *Client) SetVariable(input *SetVariableInput) (output *SetVariableOutput, err error) { + input.c = c + input.u = "https://" + url.PathEscape(c.ReamlHostname) + "/db/" + url.PathEscape(input.AppID) + + output = &SetVariableOutput{} + err = c.Do(input, output) + if err == nil { + output.Name = input.Name + output.Value = input.Value + } + + return +} diff --git a/qbclient/client.go b/qbclient/client.go new file mode 100644 index 0000000..6983ef6 --- /dev/null +++ b/qbclient/client.go @@ -0,0 +1,155 @@ +package qbclient + +import ( + "bytes" + "fmt" + "net/http" + "runtime" + + "github.com/QuickBase/quickbase-cli/qberrors" + "github.com/go-playground/validator" + "github.com/hashicorp/go-retryablehttp" + "github.com/spf13/viper" +) + +// Client makes requests to the Quick Base API. +type Client struct { + HTTPClient *http.Client + Plugins []Plugin + ReamlHostname string + TemporaryToken string + URL string + UserAgent string + UserToken string +} + +// New returns a new Client. +func New(cfg ConfigIface) *Client { + c := &Client{ + ReamlHostname: cfg.RealmHostname(), + TemporaryToken: cfg.TemporaryToken(), + URL: "https://api.quickbase.com/v1", + UserAgent: userAgent(), + UserToken: cfg.UserToken(), + } + + // Configure and set the retry handler. + rh := retryablehttp.NewClient() + rh.RetryMax = 1 + rh.Logger = nil + rh.ErrorHandler = c.errorHandler + c.HTTPClient = rh.StandardClient() + + return c +} + +// NewFromProfile returns a new Client, initializing the config from the +// passed profile. +func NewFromProfile(profile string) (client *Client, err error) { + cfg := viper.New() + cfg.SetDefault(OptionProfile, profile) + if err = ReadInConfig(cfg); err == nil { + client = New(Config{cfg: cfg}) + } + return +} + +func userAgent() string { + return fmt.Sprintf("quickbase-cli/%s, (%s %s)", "0.0.1-alpha", runtime.GOOS, runtime.GOARCH) +} + +// AddPlugin adds a Plugin to the stack. +func (c *Client) AddPlugin(p Plugin) { + c.Plugins = append(c.Plugins, p) +} + +// Do sends an arbitrary request to the Quick Base API. +// TODO Improve the error handling. +func (c *Client) Do(input Input, output Output) error { + + // Validate the input. + if err := validator.New().Struct(input); err != nil { + return qberrors.HandleErrorValidation(err) + } + + // Marshal marshals the request body using Input.marshal. + b, err := input.encode() + if err != nil { + return qberrors.Client(err).Safef(qberrors.InvalidInput, "error encoding input") + } + + // Create the request, using the marshalled input as the body. + req, err := http.NewRequest(input.method(), input.url(), bytes.NewBuffer(b)) + if err != nil { + serr := qberrors.ErrSafe{Message: "error creating request"} + return qberrors.Internal(err).Safe(serr) + } + + // Add HTTP headers using Input.addHeaders. + input.addHeaders(req) + + // Invoke each plugin's PreRequest hook. + c.invokePreRequest(req) + + // Do the HTTP request. + resp, err := c.HTTPClient.Do(req) + if err != nil { + serr := qberrors.ErrSafe{Message: "error executing request"} + return qberrors.Service(err).Safe(serr) + } + + // Invoke each plugin's PostResponse hook. + c.invokePostResponse(resp) + + // Parse the response body. We do our best to handle this gracefully if + // an error is thrown outside of the API's control plane, e.g., from + // Cloudflare, which might not produce parsable output. + if err := output.decode(resp.Body); err != nil { + switch true { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + serr := qberrors.ErrSafe{Message: "error decoding response"} + return qberrors.Internal(err).Safe(serr) + case resp.StatusCode >= 400 && resp.StatusCode < 500: + serr := qberrors.ErrSafe{Message: http.StatusText(resp.StatusCode), StatusCode: resp.StatusCode} + return qberrors.Client(serr).Safe(serr) + default: + serr := qberrors.ErrSafe{Message: http.StatusText(resp.StatusCode), StatusCode: resp.StatusCode} + return qberrors.Service(serr).Safe(serr) + } + } + + // Handle any errors, the logic of which will depend on whether we are + // consuming the XML or RESTful API. + return output.handleError(output, resp) +} + +// errorHandler implements retryablehttp.ErrorHandler by invoking post-reponse +// plugins after the error occurs. It then closes the response body and returns +// the same error message as retryablehttp.Do. +func (c *Client) errorHandler(resp *http.Response, err error, numTries int) (*http.Response, error) { + c.invokePostResponse(resp) + + if resp != nil { + resp.Body.Close() + } + + s := fmt.Sprintf("giving up after %d attempt", numTries) + if numTries > 1 { + s += "s" + } + + serr := qberrors.ErrSafe{Message: s} + return nil, qberrors.Service(err).Safe(serr) +} + +func (c *Client) invokePreRequest(req *http.Request) { + for _, plugin := range c.Plugins { + plugin.PreRequest(req) + } +} + +func (c *Client) invokePostResponse(resp *http.Response) { + for _, plugin := range c.Plugins { + plugin.PostResponse(resp) + } +} diff --git a/qbclient/client_test.go b/qbclient/client_test.go new file mode 100644 index 0000000..5a9815d --- /dev/null +++ b/qbclient/client_test.go @@ -0,0 +1,22 @@ +package qbclient_test + +import ( + "testing" + + "github.com/QuickBase/quickbase-cli/qbclient" + "github.com/spf13/viper" +) + +func TestInvalidInput(t *testing.T) { + input := &qbclient.GetVariableInput{} + + cfg := qbclient.NewConfig(viper.New()) + client := qbclient.New(cfg) + + output := &qbclient.GetVariableOutput{} + err := client.Do(input, output) + + if err == nil { + t.Fatal("got nil, expected error") + } +} diff --git a/qbclient/config.go b/qbclient/config.go new file mode 100644 index 0000000..3e58ca4 --- /dev/null +++ b/qbclient/config.go @@ -0,0 +1,178 @@ +package qbclient + +import ( + "io/ioutil" + "os" + "strings" + + "github.com/mitchellh/go-homedir" + "github.com/spf13/viper" + "gopkg.in/yaml.v2" +) + +// EnvPrefix is the prefix for environment variables containing configuration. +const EnvPrefix = "QUICKBASE" + +// ConfigFilename is the name of the configuration file. +const ConfigFilename = "config.yml" + +// Option* constants contain CLI options. +const ( + OptionAppID = "app-id" + OptionConfigDir = "config-dir" + OptionFieldID = "field-id" + OptionProfile = "profile" + OptionRealmHostname = "realm-hostname" + OptionRelationshipID = "relationship-id" + OptionTableID = "table-id" + OptionTemporaryToken = "temp-token" + OptionUserToken = "user-token" +) + +// ConfigIface is implemented by structs used to configure the cleint. +type ConfigIface interface { + + // ConfigDir returns the configuration directory. + ConfigDir() string + + // DefaultAppID returns the default app ID. + DefaultAppID() string + + // Default FieldID returns the default field ID + DefaultFieldID() int + + // DefaultTableID returns the default table ID. + DefaultTableID() string + + // Profile returns the configured profile. + Profile() string + + // RealmHostname returns the configured realm hostname. + RealmHostname() string + + // TemporaryToken returns the configured log level. + TemporaryToken() string + + // UserToken returns the configured log level. + UserToken() string +} + +// Config contains configuration for the client. +type Config struct { + cfg *viper.Viper +} + +// NewConfig returns a new config +func NewConfig(cfg *viper.Viper) Config { + return Config{cfg: cfg} +} + +// ConfigDir returns the configuration directory. +func (c Config) ConfigDir() string { return c.cfg.GetString(OptionConfigDir) } + +// DefaultAppID returns the default app ID. +func (c Config) DefaultAppID() string { return c.cfg.GetString(OptionAppID) } + +// DefaultFieldID returns the default field ID. +func (c Config) DefaultFieldID() int { return c.cfg.GetInt(OptionFieldID) } + +// DefaultTableID returns the default table ID. +func (c Config) DefaultTableID() string { return c.cfg.GetString(OptionTableID) } + +// Profile returns the configured profile. +func (c Config) Profile() string { return c.cfg.GetString(OptionProfile) } + +// RealmHostname returns the configured realm hostname. +func (c Config) RealmHostname() string { return c.cfg.GetString(OptionRealmHostname) } + +// TemporaryToken returns the configured log level. +func (c Config) TemporaryToken() string { return c.cfg.GetString(OptionTemporaryToken) } + +// UserToken returns the configured log level. +func (c Config) UserToken() string { return c.cfg.GetString(OptionUserToken) } + +// ReadInConfig reads in configuration from the config file. +func ReadInConfig(cfg *viper.Viper) error { + homeDir, err := homedir.Dir() + if err != nil { + return err + } + + // Set the default profile and configuration file directory. + cfg.SetDefault(OptionProfile, "default") + cfg.SetDefault(OptionConfigDir, Filepath(homeDir, ".config", "quickbase")) + + // Read in configuration from environment variables. + cfg.SetEnvPrefix(EnvPrefix) + cfg.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + cfg.AutomaticEnv() + + // Read the configuration file in the configuration directory if it exists. + configFile, err := ReadConfigFile(cfg.GetString(OptionConfigDir)) + if err != nil { + return err + } + + // Get the profile's configuration if set. + p := cfg.GetString(OptionProfile) + if config, ok := configFile[p]; ok { + cfg.SetDefault(OptionRealmHostname, config.RealmHostname) + cfg.SetDefault(OptionUserToken, config.UserToken) + cfg.SetDefault(OptionTemporaryToken, config.TemporaryToken) + cfg.SetDefault(OptionAppID, config.AppID) + cfg.SetDefault(OptionTableID, config.TableID) + cfg.SetDefault(OptionFieldID, config.FieldID) + } + + return nil +} + +// ReadConfigFile reads and parses the configuration file. +func ReadConfigFile(dir string) (cf ConfigFile, err error) { + cf = make(map[string]*ConfigFileProfile, 0) + + filepath := Filepath(dir, ConfigFilename) + if !FileExists(filepath) { + return + } + + var b []byte + if b, err = ioutil.ReadFile(filepath); err != nil { + return + } + + err = yaml.Unmarshal(b, &cf) + return +} + +// WriteConfigFile writes a configuration file. +func WriteConfigFile(dir string, cf ConfigFile) (err error) { + + if !DirExists(dir) { + if err = os.MkdirAll(dir, 0755); err != nil { + return + } + } + + var b []byte + if b, err = yaml.Marshal(cf); err != nil { + return + } + + filepath := Filepath(dir, ConfigFilename) + err = ioutil.WriteFile(filepath, b, 0600) + return +} + +// ConfigFile models the configuration file. +type ConfigFile map[string]*ConfigFileProfile + +// ConfigFileProfile models the configuration for a profile. +type ConfigFileProfile struct { + RealmHostname string `yaml:"realm_hostname,omitempty" json:"realm_hostname,omitempty"` + UserToken string `yaml:"user_token,omitempty" json:"user_token,omitempty"` + TemporaryToken string `yaml:"temp_token,omitempty" json:"temp_token,omitempty"` + AppID string `yaml:"app_id,omitempty" json:"app_id,omitempty"` + TableID string `yaml:"table_id,omitempty" json:"table_id,omitempty"` + FieldID int `yaml:"field_id,omitempty" json:"field_id,omitempty"` +} diff --git a/qbclient/const.go b/qbclient/const.go new file mode 100644 index 0000000..8938e13 --- /dev/null +++ b/qbclient/const.go @@ -0,0 +1,145 @@ +package qbclient + +import ( + "fmt" + "strings" +) + +// Field* constants contain the Quick Base field types. +const ( + FieldRecordID = "recordid" + FieldText = "text" + FieldTextMultiLine = "text-multi-line" + FieldTextMultipleChoice = "text-multiple-choice" + FieldRichText = "rich-text" + FieldMultiSelectText = "multitext" + FieldNumeric = "numeric" + FieldNumericCurrency = "currency" + FieldNumericPercent = "percent" + FieldNumericRating = "rating" + FieldDate = "date" + FieldDateTime = "timestamp" + FieldTimeOfDay = "timeofday" + FieldDuration = "duration" + FieldCheckbox = "checkbox" + FieldAddress = "address" + FieldAddressStreet1 = "text" + FieldAddressStreet2 = "text" + FieldAddressCity = "text" + FieldAddressStateRegion = "text" + FieldAddressPostalCode = "text" + FieldAddressCountry = "text" + FieldPhoneNumber = "phone" + FieldEmailAddress = "email" + FieldUser = "userid" + FieldUserList = "multiuserid" + FieldFileAttachment = "file" + FieldURL = "url" + FieldReportLink = "dblink" + FieldiCalendar = "ICalendarButton" + FieldvCard = "vCardButton" + FieldPredecessor = "predecessor" +) + +// FieldType returns the Quick Base field type from in, which is a string that +// contains the constant without the "Field" prefix. +func FieldType(in string) (out string, err error) { + in = strings.ToLower(strings.ReplaceAll(in, "_", "")) + + switch in { + case "recordid": + out = FieldRecordID + case "text": + out = FieldText + case "textmultiline": + out = FieldTextMultiLine + case "textmultiplechoice": + out = FieldTextMultipleChoice + case "richtext": + out = FieldRichText + case "multiselecttext", FieldMultiSelectText: + out = FieldMultiSelectText + case FieldNumeric: + out = FieldNumeric + case "numericcurrency", FieldNumericCurrency: + out = FieldNumericCurrency + case "numericpercent", FieldNumericPercent: + out = FieldNumericPercent + case "numericrating", FieldNumericRating: + out = FieldNumericRating + case "date": + out = FieldDate + case "datetime", FieldDateTime: + out = FieldDateTime + case "timeofday": + out = FieldTimeOfDay + case "duration": + out = FieldDuration + case "checkbox": + out = FieldCheckbox + case "address": + out = FieldAddress + case "addressstreet1": + out = FieldAddressStreet1 + case "addressstreet2": + out = FieldAddressStreet2 + case "addresscity": + out = FieldAddressCity + case "addressstateregion": + out = FieldAddressStateRegion + case "addresspostalcode": + out = FieldAddressPostalCode + case "addresscountry": + out = FieldAddressCountry + case "phonenumber", FieldPhoneNumber: + out = FieldPhoneNumber + case "emailaddress", FieldEmailAddress: + out = FieldEmailAddress + case "user", FieldUser: + out = FieldUser + case "userlist", FieldUserList: + out = FieldUserList + case "fileattachment", FieldFileAttachment: + out = FieldFileAttachment + case "url": + out = FieldURL + case "reportlink", FieldReportLink: + out = FieldReportLink + case "icalendar", FieldiCalendar: + out = FieldiCalendar + case "vcard", FieldvCard: + out = FieldvCard + case "predecessor": + out = FieldPredecessor + default: + err = fmt.Errorf("type not valid (%s)", in) + } + + return +} + +// AccumulationType* constants contain valid accumulation types for summary +// fields. +const ( + AccumulationTypeAverage = "AVG" + AccumulationTypeSum = "SUM" + AccumulationTypeMaximum = "MAX" + AccumulationTypeMinimum = "MIN" + AccumulationTypeStandardDeviation = "STD-DEV" + AccumulationTypeCount = "COUNT" + AccumulationTypeCombinedText = "COMBINED-TEXT" + AccumulationTypeDistinctCount = "DISTINCT-COUNT" +) + +// Format* constants contain common format strings. +const ( + FormatDate = "2006-01-02" + FormatDateTime = "2006-01-02T15:04:05Z" + FormatTimeOfDay = "15:04:05" +) + +// SortBy* constants model values used in the the order property. +const ( + SortByASC = "ASC" + SortByDESC = "DESC" +) diff --git a/qbclient/file.go b/qbclient/file.go new file mode 100644 index 0000000..dd6a694 --- /dev/null +++ b/qbclient/file.go @@ -0,0 +1,32 @@ +package qbclient + +import ( + "os" + "strings" +) + +// Filepath is a cross-platform solution for generating filepaths. +func Filepath(dirs ...string) string { + return strings.Join(dirs, string(os.PathSeparator)) +} + +// FileExists returns true if filename exists and is a file. +func FileExists(filename string) bool { + exists, isDir := exists(filename) + return exists && !isDir +} + +// DirExists returns true if dirname exists and is a directory. +func DirExists(dirname string) bool { + exists, isDir := exists(dirname) + return exists && isDir +} + +func exists(name string) (exists bool, isDir bool) { + info, err := os.Stat(name) + exists = !os.IsNotExist(err) + if err == nil { + isDir = info.IsDir() + } + return +} diff --git a/qbclient/model.go b/qbclient/model.go new file mode 100644 index 0000000..0f52adb --- /dev/null +++ b/qbclient/model.go @@ -0,0 +1,978 @@ +package qbclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/araddon/dateparse" +) + +// SetRecords sets the records to insert. +// +// This function converts the a Records slice and sets it as the +// InsertRecordsInput.Data property. +func (i *InsertRecordsInput) SetRecords(records []*Record) { + i.Data = make([]map[int]*InsertRecordsInputData, len(records)) + for n, r := range records { + data := make(map[int]*InsertRecordsInputData) + for fid, val := range r.Fields { + data[fid] = &InsertRecordsInputData{Value: val} + } + i.Data[n] = data + } +} + +// +// Developer-friendly models for records and fields. The custom marshaler and +// unmarshaler of the Value is where serialization and deserialization +// happen. +// + +// Record models a record in Quick Base. +type Record struct { + Fields map[int]*Value +} + +// SetValue sets a value for a field. +func (r *Record) SetValue(fid int, val *Value) { + if len(r.Fields) == 0 { + r.Fields = make(map[int]*Value) + } + r.Fields[fid] = val +} + +// Value models the value of fields in Quick Base. This struct effectively +// handles the Quick base field type / Golang type transformations. +type Value struct { + Bool bool + Duration time.Duration + Float64 float64 + String string + StringSlice []string + Time time.Time + URL *url.URL + User *User + UserSlice []*User + + QuickBaseType string +} + +// NewRecordIDValue returns a new Value of the FieldRecordID type. +func NewRecordIDValue(val float64) *Value { + return &Value{Float64: val, QuickBaseType: FieldRecordID} +} + +// NewTextValue returns a new Value of the FieldText type. +func NewTextValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldText} +} + +// NewTextMultiLineValue returns a new Value of the FieldTextMultiLine type. +func NewTextMultiLineValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldTextMultiLine} +} + +// NewTextMultipleChoiceValue returns a new Value of the FieldTextMultipleChoice type. +func NewTextMultipleChoiceValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldTextMultipleChoice} +} + +// NewRichTextValue returns a new Value of the FieldRichText type. +func NewRichTextValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldRichText} +} + +// NewMultiSelectTextValue returns a new Value of the FieldMultiSelectText type. +func NewMultiSelectTextValue(val []string) *Value { + return &Value{StringSlice: val, QuickBaseType: FieldMultiSelectText} +} + +// NewMultiSelectTextValueFromString returns a new Value of the FieldMultiSelectText +// type given a string with a comma-separated list of values. +func NewMultiSelectTextValueFromString(val string) (v *Value, err error) { + var ss []string + if ss, err = ParseList(val); err == nil { + v = NewMultiSelectTextValue(ss) + } + return +} + +// NewNumericValue returns a new Value of the FieldNumeric type. +func NewNumericValue(val float64) *Value { + return &Value{Float64: val, QuickBaseType: FieldNumeric} +} + +// NewNumericValueFromString returns a new Value of the FieldNumeric type +// given a string. +func NewNumericValueFromString(val string) (*Value, error) { + return parseStringToNumericValue(val, FieldNumeric) +} + +// NewNumericCurrencyValue returns a new Value of the FieldNumericCurrency type. +func NewNumericCurrencyValue(val float64) *Value { + return &Value{Float64: val, QuickBaseType: FieldNumericCurrency} +} + +// NewNumericCurrencyValueFromString returns a new Value of the +// FieldNumericCurrency type given a string +func NewNumericCurrencyValueFromString(val string) (*Value, error) { + return parseStringToNumericValue(val, FieldNumericCurrency) +} + +// NewNumericPercentValue returns a new Value of the FieldNumericPercent type. +func NewNumericPercentValue(val float64) *Value { + return &Value{Float64: val, QuickBaseType: FieldNumericPercent} +} + +// NewNumericPercentValueFromString returns a new Value of the FieldNumericPercent +// type given a string. +func NewNumericPercentValueFromString(val string) (*Value, error) { + return parseStringToNumericValue(val, FieldNumericPercent) +} + +// NewNumericRatingValue returns a new Value of the FieldNumericRating type. +func NewNumericRatingValue(val float64) *Value { + return &Value{Float64: val, QuickBaseType: FieldNumericRating} +} + +// NewNumericRatingValueFromString returns a new Value of the FieldNumericRating +// type given a string. +func NewNumericRatingValueFromString(val string) (*Value, error) { + return parseStringToNumericValue(val, FieldNumericRating) +} + +// NewDateValue returns a new Value of the FieldDate type. +func NewDateValue(val time.Time) *Value { + return &Value{Time: val, QuickBaseType: FieldDate} +} + +// NewDateValueFromString returns a new Value of the FieldDate type, +// parsing the passed string into a time.Time. +func NewDateValueFromString(val string) (*Value, error) { + return parseTimeToValue(val, FieldDate, dateparse.ParseAny) +} + +// NewDateTimeValue returns a new Value of the FieldDateTime type. +func NewDateTimeValue(val time.Time) *Value { + return &Value{Time: val, QuickBaseType: FieldDateTime} +} + +// NewDateTimeValueFromString returns a new Value of the FieldDate type, +// parsing the passed string into a time.Time. +func NewDateTimeValueFromString(val string) (*Value, error) { + return parseTimeToValue(val, FieldDateTime, dateparse.ParseLocal) +} + +// NewTimeOfDayValue returns a new Value of the FieldTimeOfDay type. +func NewTimeOfDayValue(val time.Time) *Value { + return &Value{Time: val, QuickBaseType: FieldTimeOfDay} +} + +// NewTimeOfDayValueFromString returns a new Value of the FieldDate type, +// parsing the passed string into a time.Time. +func NewTimeOfDayValueFromString(val string) (*Value, error) { + return parseTimeToValue("3/19/1982 "+val, FieldTimeOfDay, dateparse.ParseAny) +} + +// NewDurationValue returns a new Value of the FieldDuration type. +func NewDurationValue(val time.Duration) *Value { + return &Value{Duration: val, QuickBaseType: FieldDuration} +} + +// NewDurationValueFromFloat64 returns a new Value of the FieldDuration type, +// converting the passed float64 into a duration. We assume that the float64 is +// the duration in milliseconds. +func NewDurationValueFromFloat64(val float64) *Value { + return NewDurationValue(time.Millisecond * time.Duration(val)) +} + +// NewDurationValueFromString returns a new Value of the FieldDuration type +// given a passed string. +func NewDurationValueFromString(val string) (v *Value, err error) { + var d time.Duration + if d, err = time.ParseDuration(val); err == nil { + v = NewDurationValue(d) + } + return +} + +// NewCheckboxValue returns a new Value of the FieldCheckbox type. +func NewCheckboxValue(val bool) *Value { + return &Value{Bool: val, QuickBaseType: FieldCheckbox} +} + +// NewCheckboxValueFromString returns a new Value of the FieldCheckbox type +// given a passed string. +func NewCheckboxValueFromString(val string) (v *Value, err error) { + var b bool + if b, err = strconv.ParseBool(val); err != nil { + v = NewCheckboxValue(b) + } + return +} + +// NewAddressValue returns a new Value of the FieldAddress type. +func NewAddressValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldAddress} +} + +// NewAddressStreet1Value returns a new Value of the FieldAddressStreet1 type. +func NewAddressStreet1Value(val string) *Value { + return &Value{String: val, QuickBaseType: FieldAddressStreet1} +} + +// NewAddressStreet2Value returns a new Value of the FieldAddressStreet2 type. +func NewAddressStreet2Value(val string) *Value { + return &Value{String: val, QuickBaseType: FieldAddressStreet2} +} + +// NewAddressCityValue returns a new Value of the FieldAddressCity type. +func NewAddressCityValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldAddressCity} +} + +// NewAddressStateRegionValue returns a new Value of the FieldAddressStateRegion type. +func NewAddressStateRegionValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldAddressStateRegion} +} + +// NewAddressPostalCodeValue returns a new Value of the FieldAddressPostalCode type. +func NewAddressPostalCodeValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldAddressPostalCode} +} + +// NewAddressCountryValue returns a new Value of the FieldAddressCountry type. +func NewAddressCountryValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldAddressCountry} +} + +// NewPhoneNumberValue returns a new Value of the FieldPhoneNumber type. +func NewPhoneNumberValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldPhoneNumber} +} + +// NewEmailAddressValue returns a new Value of the FieldEmailAddress type. +func NewEmailAddressValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldEmailAddress} +} + +// NewUserValue returns a new Value of the FieldUser type. +func NewUserValue(val *User) *Value { + return &Value{User: val, QuickBaseType: FieldUser} +} + +// NewUserValueFromString returns a new Value of the FieldUser type given a +// passed string. +func NewUserValueFromString(val string) *Value { + return NewUserValue(&User{ID: val}) +} + +// NewListUserValue returns a new Value of the FieldUserList type. +func NewListUserValue(val []*User) *Value { + return &Value{UserSlice: val, QuickBaseType: FieldUserList} +} + +// NewListUserValueFromString returns a new Value of the FieldUserList type +// given a passed string. +func NewListUserValueFromString(val string) *Value { + return &Value{UserSlice: []*User{}, QuickBaseType: FieldUserList} +} + +// NewFileAttachmentValue returns a new Value of the FieldFileAttachment type. +func NewFileAttachmentValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldFileAttachment} +} + +// NewReportLinkValue returns a new Value of the FieldReportLink type. +func NewReportLinkValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldReportLink} +} + +// NewURLValue returns a new Value of the FieldURL type. +func NewURLValue(val *url.URL) *Value { + return &Value{URL: val, QuickBaseType: FieldURL} +} + +// NewURLValueFromString returns a new Value of the FieldURL type. +func NewURLValueFromString(val string) (v *Value, err error) { + var u *url.URL + if u, err = url.Parse(val); err != nil { + v = &Value{URL: u, QuickBaseType: FieldURL} + } + return +} + +// NewiCalendarValue returns a new Value of the FieldiCalendar type. +// Make this a URL? +func NewiCalendarValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldiCalendar} +} + +// NewvCardValue returns a new Value of the FieldvCard type. +// Make this a URL? +func NewvCardValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldvCard} +} + +// NewPredecessorValue returns a new Value of the FieldPredecessor type. +func NewPredecessorValue(val string) *Value { + return &Value{String: val, QuickBaseType: FieldPredecessor} +} + +type dateparseFn func(string, ...dateparse.ParserOption) (time.Time, error) + +func parseTimeToValue(val, ftype string, fn dateparseFn) (v *Value, err error) { + var t time.Time + if val == "" { + v = &Value{Time: t, QuickBaseType: ftype} + } else if t, err = fn(val); err == nil { + v = &Value{Time: t, QuickBaseType: ftype} + } + return +} + +func parseStringToNumericValue(val, ftype string) (v *Value, err error) { + var f float64 + if f, err = strconv.ParseFloat(val, 64); err == nil { + v = &Value{Float64: f, QuickBaseType: ftype} + } + return +} + +// NewValueFromString returns a new *Value from a string given the Quick Base +// field type. +func NewValueFromString(val, ftype string) (v *Value, err error) { + switch ftype { + + case FieldText: + // Also picks up: + // FieldAddressStreet1, FieldAddressStreet2, FieldAddressCity, + // FieldAddressStateRegion, FieldAddressPostalCode, and + // FieldAddressCountry + v = NewTextValue(val) + + case FieldTextMultiLine: + v = NewTextMultiLineValue(val) + + case FieldTextMultipleChoice: + v = NewTextMultipleChoiceValue(val) + + case FieldRichText: + v = NewRichTextValue(val) + + case FieldMultiSelectText: + v, err = NewMultiSelectTextValueFromString(val) + + case FieldNumeric: + v, err = NewNumericValueFromString(val) + + case FieldNumericCurrency: + v, err = NewNumericCurrencyValueFromString(val) + + case FieldNumericPercent: + v, err = NewNumericCurrencyValueFromString(val) + + case FieldNumericRating: + v, err = NewNumericPercentValueFromString(val) + + case FieldDate: + v, err = NewDateValueFromString(val) + + case FieldDateTime: + v, err = NewDateTimeValueFromString(val) + + case FieldTimeOfDay: + v, err = NewTimeOfDayValueFromString(val) + + case FieldDuration: + v, err = NewDurationValueFromString(val) + + case FieldCheckbox: + v, err = NewCheckboxValueFromString(val) + + case FieldAddress: + v = NewAddressValue(val) + + case FieldPhoneNumber: + v = NewPhoneNumberValue(val) + + case FieldEmailAddress: + v = NewEmailAddressValue(val) + + case FieldUser: + v = NewUserValueFromString(val) + + case FieldUserList: + v = NewListUserValueFromString(val) + + case FieldFileAttachment: + v = NewFileAttachmentValue(val) + + case FieldReportLink: + v = NewReportLinkValue(val) + + case FieldURL: + v, err = NewURLValueFromString(val) + + case FieldiCalendar: + v = NewiCalendarValue(val) + + case FieldvCard: + v = NewvCardValue(val) + + case FieldPredecessor: + v = NewPredecessorValue(val) + + default: + err = fmt.Errorf("unsupported field type (%s)", ftype) + } + + return +} + +// MarshalJSON implements json.MarshalJSON and JSON encodes the value. +// TODO Marshal by Quick Base type instead, because we have to format dates differently. +func (v *Value) MarshalJSON() ([]byte, error) { + switch v.QuickBaseType { + + case FieldRecordID: + return json.Marshal(v.Float64) + + case FieldText, FieldTextMultiLine, FieldTextMultipleChoice, FieldRichText: + // Also picks up: + // FieldAddressStreet1, FieldAddressStreet2, FieldAddressCity, + // FieldAddressStateRegion, FieldAddressPostalCode, and + // FieldAddressCountry + return json.Marshal(v.String) + + case FieldMultiSelectText: + return json.Marshal(v.StringSlice) + + case FieldNumeric, FieldNumericCurrency, FieldNumericPercent, FieldNumericRating: + return json.Marshal(v.Float64) + + case FieldDate: + s := v.Time.UTC().Format(FormatDate) + return json.Marshal(s) + + case FieldDateTime: + s := v.Time.UTC().Format(FormatDateTime) + return json.Marshal(s) + + case FieldTimeOfDay: + s := v.Time.UTC().Format(FormatTimeOfDay) + return json.Marshal(s) + + case FieldDuration: + return json.Marshal(v.Duration.Milliseconds()) + + case FieldCheckbox: + return json.Marshal(v.Bool) + + case FieldAddress: + return json.Marshal(v.String) + + case FieldPhoneNumber: + return json.Marshal(v.String) + + case FieldEmailAddress: + return json.Marshal(v.String) + + case FieldUser: + return json.Marshal(v.User) + + case FieldUserList: + return json.Marshal(v.UserSlice) + + // TODO revisit when re-added. + case FieldFileAttachment: + return json.Marshal(v.String) + + case FieldReportLink: + return json.Marshal(v.String) + + case FieldURL: + return json.Marshal(v.URL.String()) + + // Deprecated + case FieldiCalendar: + return json.Marshal(v.String) + + // Deprecated + case FieldvCard: + return json.Marshal(v.String) + + case FieldPredecessor: + return json.Marshal(v.String) + + default: + return []byte(``), fmt.Errorf("unsupported field type (%s)", v.QuickBaseType) + } +} + +func unmarshalField(fid int, ftype string, data *json.RawMessage) (val *Value, err error) { + switch ftype { + + case FieldRecordID: + var v float64 + if data == nil { + val = NewNumericValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewNumericValue(v) + } + + case FieldText: + // Also picks up: + // FieldAddressStreet1, FieldAddressStreet2, FieldAddressCity, + // FieldAddressStateRegion, FieldAddressPostalCode, and + // FieldAddressCountry + var v string + if data == nil { + val = NewTextValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewTextValue(v) + } + + case FieldTextMultiLine: + var v string + if data == nil { + val = NewTextMultiLineValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewTextMultiLineValue(v) + } + + case FieldTextMultipleChoice: + var v string + if data == nil { + val = NewTextMultipleChoiceValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewTextMultipleChoiceValue(v) + } + + case FieldRichText: + var v string + if data == nil { + val = NewRichTextValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewRichTextValue(v) + } + + case FieldMultiSelectText: + var v []string + if data == nil { + val = NewMultiSelectTextValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewMultiSelectTextValue(v) + } + + case FieldNumeric, FieldNumericCurrency, FieldNumericPercent, FieldNumericRating: + var v float64 + if data == nil { + val = NewNumericValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewNumericValue(v) + } + + case FieldDate: + var v string + if data == nil { + val, err = NewDateValueFromString(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val, err = NewDateValueFromString(v) + } + + case FieldDateTime: + var v string + if data == nil { + val, err = NewDateTimeValueFromString(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val, err = NewDateTimeValueFromString(v) + } + + case FieldTimeOfDay: + var v string + if data == nil { + val, err = NewTimeOfDayValueFromString(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val, err = NewTimeOfDayValueFromString(v) + } + + case FieldDuration: + var v float64 + if data == nil { + val = NewDurationValueFromFloat64(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewDurationValueFromFloat64(v) + } + + case FieldCheckbox: + var v bool + if data == nil { + val = NewCheckboxValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewCheckboxValue(v) + } + + case FieldAddress: + var v string + if data == nil { + val = NewAddressValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewAddressValue(v) + } + + case FieldPhoneNumber: + var v string + if data == nil { + val = NewPhoneNumberValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewPhoneNumberValue(v) + } + + case FieldEmailAddress: + var v string + if data == nil { + val = NewEmailAddressValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewEmailAddressValue(v) + } + + case FieldUser: + var v *User + if data == nil { + val = NewUserValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewUserValue(v) + } + + case FieldUserList: + var v []*User + if data == nil { + val = NewListUserValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewListUserValue(v) + } + + case FieldFileAttachment: + var v string + if data == nil { + val = NewEmailAddressValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewEmailAddressValue(v) + } + + case FieldReportLink: + var v string + if data == nil { + val = NewReportLinkValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewReportLinkValue(v) + } + + case FieldURL: + var v string + if data == nil { + val, err = NewURLValueFromString(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val, err = NewURLValueFromString(v) + } + + case FieldiCalendar: + var v string + if data == nil { + val = NewiCalendarValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewiCalendarValue(v) + } + + case FieldvCard: + var v string + if data == nil { + val = NewPredecessorValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewPredecessorValue(v) + } + + case FieldPredecessor: + var v string + if data == nil { + val = NewPredecessorValue(v) + } else if err = json.Unmarshal(*data, &v); err == nil { + val = NewPredecessorValue(v) + } + + default: + err = fmt.Errorf("unsupported field type (%s)", ftype) + } + + if err != nil { + err = fmt.Errorf("%s (fid %v)", err, fid) + } + + return +} + +// UnmarshalJSON implements json.UnmarshalJSON by using the field type to +// decode the "value" parameter into the appropraite data type. +func (output *QueryRecordsOutput) UnmarshalJSON(b []byte) (err error) { + + // Unmarshal the json into our parsing struct. + var v parseQueryRecordsOutput + if err = json.Unmarshal(b, &v); err != nil { + return + } + + // Set the parsed value for everything but the "data" property. + output.Message = v.Message + output.Description = v.Description + output.Fields = v.Fields + output.Metadata = v.Metadata + + // Build a mapping of field IDs to Quick Base field type. + tmap := make(map[int]string, len(v.Fields)) + for _, fd := range v.Fields { + tmap[fd.FieldID] = fd.Type + } + + // Parse the field values now that we have the Quick Base field types. + output.Data = make([]map[int]*QueryRecordsOutputData, len(v.Data)) + for i, record := range v.Data { + + data := make(map[int]*QueryRecordsOutputData, len(record)) + for fid, field := range record { + + // Get the Quick Base field type from the fid. + ftype, ok := tmap[fid] + if !ok { + err = fmt.Errorf("field type not found (fid %v)", fid) + return + } + + // Unmarshal the field based on its Quick Base type. + var val *Value + if val, err = unmarshalField(fid, ftype, field.Value); err != nil { + return + } + + data[fid] = &QueryRecordsOutputData{Value: val} + } + + output.Data[i] = data + } + + return +} + +type parseQueryRecordsOutput struct { + ErrorProperties + + Fields []*QueryRecordsOutputFields `json:"fields,omitempty"` + Metadata *QueryRecordsOutputMetadata `json:"metadata,omitempty"` + Data []map[int]struct { + Value *json.RawMessage `json:"value"` + } `json:"data,omitempty"` +} + +// +// Models for Quick Base field values. +// + +// Timestamp models a unix timestamp in Quick Base. +type Timestamp struct { + time.Time +} + +// MarshalJSON converts time.Time to a unix timestamp in microseconds. +func (t Timestamp) MarshalJSON() ([]byte, error) { + buf := bytes.NewBuffer([]byte{}) + json.NewEncoder(buf).Encode(t.Time.Format(FormatDateTime)) + return buf.Bytes(), nil +} + +// UnmarshalJSON converts a unix timestamp in microseconds to a time.Time. +func (t *Timestamp) UnmarshalJSON(b []byte) error { + + var s string + buf := bytes.NewBufferString(string(b)) + err := json.NewDecoder(buf).Decode(&s) + if err != nil { + return fmt.Errorf("error decoding timestamp (%s)", err) + } + + t.Time, err = time.Parse(FormatDateTime, s) + if err != nil { + return fmt.Errorf("error parsing timestamp (%s)", err) + } + + return nil +} + +// User models a user in Quick Base. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} + +// +// Field Properties +// + +// Field models a field. +type Field struct { + + // Basics + Label string `json:"label,omitempty" validate:"required"` + Type string `json:"fieldType,omitempty" validate:"required"` + Required bool `json:"required,omitempty"` + Unique bool `json:"unique,omitempty"` + + // Display + DisplayInBold bool `json:"bold,omitempty"` + DisplayWithoutWrapping bool `json:"noWrap,omitempty"` + + // Advanced + AutoFill bool `json:"doesDataCopy,omitempty"` + Searchable bool `json:"findEnabled"` // Defaults to true, so we cannot omitempty. + AddToNewReports bool `json:"appearsByDefault"` // Defaults to true, so we cannot omitempty. + FieldHelpText string `json:"fieldHelp,omitempty"` + TrackField bool `json:"audited,omitempty"` + + // No UI + AddToForms bool `json:"addToForms,omitempty"` // Not documented +} + +// FieldProperties models field properties. +// TODO Make a custom unmarshaler to not show properties if the struct ie empty. +// SEE https://stackoverflow.com/a/28447372 +type FieldProperties struct { + + // Basics + DefaultValue string `json:"defaultValue,omitempty"` + + // Text - Multiple Choice field options + AllowNewChoices bool `json:"allowNewChoices,omitempty"` + SortChoicesAsGiven bool `json:"sortAsGiven,omitempty"` + + // Display + NumberOfLines int `json:"numLines,omitempty"` + MaxCharacters int `json:"maxLength,omitempty"` + WidthOfInputBox int `json:"width,omitempty"` + + // No UI + ExactMatch bool `json:"exact,omitempty"` + ForeignKey bool `json:"foreignKey,omitempty"` + Formula string `json:"formula,omitempty"` + ParentTable string `json:"masterTableTag,omitempty"` + PrimaryKey bool `json:"primaryKey,omitempty"` + RelatedField int `json:"targetFieldId,omitempty"` + + // Comments + Comments string `json:"comments,omitempty"` +} + +// FieldPermission models the permissions properties. +type FieldPermission struct { + Role string `json:"role"` + Type string `json:"permissionType"` + RoleID int `json:"roleId"` +} + +// +// Relationship Properties +// + +// Relationship models a relationship. +type Relationship struct { + ChildTableID string `json:"childTableId,omitempty"` + ForeignKeyField *RelationshipField `json:"foreignKeyField,omitempty"` + RelationshipID int `json:"id,omitempty"` + IsCrossApp bool `json:"isCrossApp,omitempty"` + LookupFields []*RelationshipField `json:"lookupFields,omitempty"` + ParentTableID string `json:"parentTableId,omitempty"` + SummaryFields []*RelationshipField `json:"summaryFields,omitempty"` +} + +// RelationshipField models fields in relationship output. +type RelationshipField struct { + FieldID int `json:"id,omitempty"` + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` +} + +// RelationshipSummaryField models summary fields in relationship input/output. +type RelationshipSummaryField struct { + SummaryFieldID int `json:"summaryFid,omitempty"` + Label string `json:"label,omitempty"` + AccumulationType string `json:"accumulationType,omitempty"` + Where string `json:"where,omitempty"` +} + +func relationshipPath(tid string, rid int) string { + return "/tables/" + url.PathEscape(tid) + "/relationship/" + strconv.Itoa(rid) +} + +// +// App Properties +// + +// App models an app. +// NOTE The description property is in ErrorProperties. +type App struct { + AppID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + TimeZone string `json:"timeZone,omitempty"` + DateFormat string `json:"dateFormat,omitempty"` + Created *Timestamp `json:"created,omitempty"` + Updated *Timestamp `json:"updated,omitempty"` + Variables []*Variable `json:"variables,omitempty"` + HasEveryoneOnTheInternet bool `json:"hasEveryoneOnTheInternet,omitempty"` +} + +// Variable models a variable. +type Variable struct { + Name string `xml:"-" json:"name,omitempty"` + Value string `xml:"value,omitempty" json:"value,omitempty"` +} + +// +// Input / Output interfaces and convenience functions. +// + +// Input models the payload of API requests. +type Input interface { + + // url returns the URL the API request is sent to. + url() string + + // method is the HTTP method used when sending API requests. + method() string + + // addHeaders adds HTTP headers to the API request. + addHeaders(req *http.Request) + + // encode encodes the request and writes it to io.Writer. + encode() ([]byte, error) +} + +// Output models the payload of API responses. +type Output interface { + + // decode parses the response in io.ReadCloser to Output. + decode(io.ReadCloser) error + + // errorMessage returns the error message, if any. + errorMessage() string + + // errorDetail returns the error detail, if any. + errorDetail() string + + // handleError handles errors returned by the API. + handleError(Output, *http.Response) error +} diff --git a/qbclient/model_json.go b/qbclient/model_json.go new file mode 100644 index 0000000..c6fdf76 --- /dev/null +++ b/qbclient/model_json.go @@ -0,0 +1,67 @@ +package qbclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/QuickBase/quickbase-cli/qberrors" +) + +// ErrorProperties contains properties returned during errors. +type ErrorProperties struct { + Message string `json:"message,omitempty"` + Description string `json:"description,omitempty"` +} + +func (p *ErrorProperties) errorMessage() string { return p.Message } +func (p *ErrorProperties) errorDetail() string { return p.Description } + +func (p *ErrorProperties) handleError(output Output, resp *http.Response) (err error) { + switch true { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return + case resp.StatusCode >= 400 && resp.StatusCode < 500: + serr := qberrors.ErrSafe{Message: output.errorMessage(), StatusCode: resp.StatusCode} + return qberrors.Client(serr).Safef(serr, "%s", output.errorDetail()) + default: + serr := qberrors.ErrSafe{Message: output.errorMessage(), StatusCode: resp.StatusCode} + return qberrors.Service(serr).Safef(serr, "%s", output.errorDetail()) + } +} + +// addHeadersJSON adds heads required for JSON requests. +func addHeadersJSON(req *http.Request, c *Client) { + req.Header.Add("Content-Type", "application/json") + req.Header.Add("QB-Realm-Hostname", c.ReamlHostname) + req.Header.Add("User-Agent", c.UserAgent) + + var authstr string + if c.TemporaryToken != "" { + authstr = fmt.Sprintf("QB-TEMP-TOKEN %s", c.TemporaryToken) + } else if c.UserToken != "" { + authstr = fmt.Sprintf("QB-USER-TOKEN %s", c.UserToken) + } + if authstr != "" { + req.Header.Add("Authorization", authstr) + } +} + +// marshalJSON marshals the API request into JSON. This function is intended to +// be used in Input.marshal implementations. +func marshalJSON(input interface{}) (b []byte, err error) { + if b, err = json.Marshal(input); err == nil { + if bytes.Equal(b, []byte(`{}`)) { + b = []byte(``) + } + } + return +} + +// unmarshalJSON unmarshals a JSON API response into an Output. This function +// is intended to be used in Output.unmarshal implementations. +func unmarshalJSON(body io.ReadCloser, output interface{}) error { + return json.NewDecoder(body).Decode(&output) +} diff --git a/qbclient/model_xml.go b/qbclient/model_xml.go new file mode 100644 index 0000000..2ab0373 --- /dev/null +++ b/qbclient/model_xml.go @@ -0,0 +1,144 @@ +package qbclient + +import ( + "encoding/xml" + "io" + "net/http" + "strings" + + "github.com/QuickBase/quickbase-cli/qberrors" +) + +// XMLInput is implemented by requests to the XML API. +type XMLInput interface { + Input + + // setAppToken sets the app token credential. + setAppToken(token string) + + // setTicket sets the ticket credential. + setTicket(token string) + + // setUserToken sets the user token credential. + setUserToken(token string) +} + +// XMLRequestParameters models common XML API request parameters. +type XMLRequestParameters struct { + XMLName xml.Name `xml:"qdbapi"` + UserData string `xml:"udata,omitempty"` +} + +// XMLCredentialParameters models XML API credentials parameters and implements +// XMLInput. +type XMLCredentialParameters struct { + AppToken string `xml:"apptoken,omitempty"` + Ticket string `xml:"ticket,omitempty"` + UserToken string `xml:"usertoken,omitempty"` +} + +func (p *XMLCredentialParameters) setAppToken(token string) { p.AppToken = token } +func (p *XMLCredentialParameters) setTicket(token string) { p.Ticket = token } +func (p *XMLCredentialParameters) setUserToken(token string) { p.UserToken = token } + +// XMLResponseParameters models common XML API response parameters and +// implements Output. +type XMLResponseParameters struct { + XMLName xml.Name `xml:"qdbapi" json:"-"` + Action string `xml:"action" json:"-"` + ErrorCode int `xml:"errcode" json:"errorCode,omitempty"` + ErrorText string `xml:"errtext" json:"errorMessage,omitempty"` + ErrorDetail string `xml:"errdetail" json:"errorDetail,omitempty"` + UserData string `xml:"udata,omitempty" json:"userData,omitempty"` +} + +func (p *XMLResponseParameters) errorMessage() string { return p.ErrorText } +func (p *XMLResponseParameters) errorDetail() string { return p.ErrorDetail } + +// handleError handles XML API errors. +// See https://help.quickbase.com/api-guide/errorcodes.html +// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status +func (p *XMLResponseParameters) handleError(output Output, resp *http.Response) (err error) { + if p.ErrorCode == 0 { + p.ErrorText = "" + return + } + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + msg := strings.ToLower(http.StatusText(resp.StatusCode)) + serr := qberrors.ErrSafe{Message: msg, StatusCode: resp.StatusCode} + return qberrors.Client(serr) + } + + if resp.StatusCode >= 500 { + msg := strings.ToLower(http.StatusText(resp.StatusCode)) + serr := qberrors.ErrSafe{Message: msg, StatusCode: resp.StatusCode} + return qberrors.Service(serr) + } + + serr := qberrors.ErrSafe{Message: output.errorMessage()} + switch p.ErrorCode { + case 1: + serr.StatusCode = http.StatusInternalServerError + case 2: + serr.StatusCode = p.statusFromCode2(output) + case 3, 7, 19, 23, 24, 29, 34, 38, 70, 71, 73, 74, 77, 78, 114, 150, 151, 152: + serr.StatusCode = http.StatusForbidden + case 4, 13, 20, 21, 22, 27, 28, 83: + serr.StatusCode = http.StatusUnauthorized + case 5: + serr.StatusCode = http.StatusNotAcceptable + case 6, 8, 9, 10, 11, 12, 14, 15, 25, 26, 50, 51, 52, 53, 75, 76, 80, 87, 102, 103, 110: + serr.StatusCode = http.StatusBadRequest + case 30, 31, 32, 33, 35, 37, 54, 81, 112: + serr.StatusCode = http.StatusNotFound + case 36, 84, 85: + serr.StatusCode = http.StatusInternalServerError + case 60, 61: + serr.StatusCode = http.StatusConflict + case 82: + serr.StatusCode = http.StatusGatewayTimeout + case 100, 101, 105: + serr.StatusCode = http.StatusServiceUnavailable + case 104: + serr.StatusCode = http.StatusTooManyRequests + case 111, 113: + serr.StatusCode = http.StatusUnprocessableEntity + } + + err = qberrors.Client(nil).Safef(serr, "%s", output.errorDetail()) + return +} + +// statusFromCode2 does its best to find a staus code for the error that +// resulted in a Quickbase error code 2. +func (p *XMLResponseParameters) statusFromCode2(output Output) int { + if strings.Contains(p.ErrorDetail, "not found") { + return http.StatusNotFound + } + + return http.StatusBadRequest +} + +// marshalXML marshals the API request into XML. This function is intended to +// be used in Input.marshal implementations. +func marshalXML(input XMLInput, c *Client) ([]byte, error) { + // Add credentials. + // TODO Support Tickets and App Tokens? + // See https://github.com/QuickBase/quickbase-sdk-go/blob/master/creds.go#L26 + if c.UserToken != "" { + input.setUserToken(c.UserToken) + } + + return xml.Marshal(input) +} + +func unmarshalXML(body io.ReadCloser, output Output) error { + return xml.NewDecoder(body).Decode(&output) +} + +// addHeadersJSON adds heads required for JSON requests. +func addHeadersXML(req *http.Request, c *Client, action string) { + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("QUICKBASE-ACTION", action) +} diff --git a/qbclient/parse.go b/qbclient/parse.go new file mode 100644 index 0000000..5b4cae6 --- /dev/null +++ b/qbclient/parse.go @@ -0,0 +1,18 @@ +package qbclient + +import ( + "bytes" + "encoding/csv" + "io" +) + +// ParseList parses a string into a string slice. +func ParseList(in string) (out []string, err error) { + buf := bytes.NewBufferString(in) + r := csv.NewReader(buf) + out, err = r.Read() + if err == io.EOF { + err = nil + } + return +} diff --git a/qbclient/plugin.go b/qbclient/plugin.go new file mode 100644 index 0000000..f4f19ad --- /dev/null +++ b/qbclient/plugin.go @@ -0,0 +1,10 @@ +package qbclient + +import "net/http" + +// Plugin is implemented by plugins that intercept the HTTP request and +// response when consuming the Quick Base API. +type Plugin interface { + PreRequest(req *http.Request) + PostResponse(resp *http.Response) +} diff --git a/qbclient/resource_apps.go b/qbclient/resource_apps.go new file mode 100644 index 0000000..f561946 --- /dev/null +++ b/qbclient/resource_apps.go @@ -0,0 +1,261 @@ +package qbclient + +import ( + "encoding/json" + "io" + "net/http" + "net/url" +) + +// CreateAppInput models the input sent to POST /v1/apps. +// See https://developer.quickbase.com/operation/createApp +type CreateAppInput struct { + c *Client + u string + + Name string `json:"name" validate:"required" cliutil:"option=name usage='name of the app'"` + Description string `json:"description,omitempty" cliutil:"option=description usage='description of the app'"` + AssignUserToken bool `json:"assignToken,omitempty" cliutil:"option=assign-token usage='assign the user token to the app'"` + Variable []*Variable `json:"variables,omitempty"` +} + +func (i *CreateAppInput) url() string { return i.u } +func (i *CreateAppInput) method() string { return http.MethodPost } +func (i *CreateAppInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *CreateAppInput) encode() ([]byte, error) { return marshalJSON(i) } + +// CreateAppOutput models the output returned by POST /v1/apps. +// See https://developer.quickbase.com/operation/createApp +type CreateAppOutput struct { + ErrorProperties + App +} + +func (o *CreateAppOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// CreateApp sends a request to POST /v1/apps. +// See https://developer.quickbase.com/operation/getApp +func (c *Client) CreateApp(input *CreateAppInput) (output *CreateAppOutput, err error) { + input.c = c + input.u = c.URL + "/apps" + output = &CreateAppOutput{} + err = c.Do(input, output) + return +} + +// GetAppInput models the input sent to GET /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/getApp +type GetAppInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required" cliutil:"option=app-id"` +} + +func (i *GetAppInput) url() string { return i.u } +func (i *GetAppInput) method() string { return http.MethodGet } +func (i *GetAppInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *GetAppInput) encode() ([]byte, error) { return marshalJSON(i) } + +// GetAppOutput models the output returned by GET /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/getApp +type GetAppOutput struct { + ErrorProperties + App +} + +func (o *GetAppOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// GetApp sends a request to GET /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/getApp +func (c *Client) GetApp(input *GetAppInput) (output *GetAppOutput, err error) { + input.c = c + input.u = c.URL + "/apps/" + url.PathEscape(input.AppID) + output = &GetAppOutput{} + err = c.Do(input, output) + return +} + +// GetAppByID sends a request to GET /v1/apps/{appId} and gets an app by ID. +// See https://developer.quickbase.com/operation/getApp +func (c *Client) GetAppByID(id string) (*GetAppOutput, error) { + return c.GetApp(&GetAppInput{AppID: id}) +} + +// UpdateAppInput models the input sent to POST /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/updateApp +type UpdateAppInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required" cliutil:"option=app-id"` + Name string `json:"name,omitempty" cliutil:"option=name usage='name of the app'"` + Description string `json:"description,omitempty" cliutil:"option=description usage='description of the app'"` + Variable []*Variable `json:"variables,omitempty"` +} + +func (i *UpdateAppInput) url() string { return i.u } +func (i *UpdateAppInput) method() string { return http.MethodPost } +func (i *UpdateAppInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *UpdateAppInput) encode() ([]byte, error) { return marshalJSON(i) } + +// UpdateAppOutput models the output returned by POST /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/updateApp +type UpdateAppOutput struct { + ErrorProperties + App +} + +func (o *UpdateAppOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// UpdateApp sends a request to POST /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/updateApp +func (c *Client) UpdateApp(input *UpdateAppInput) (output *UpdateAppOutput, err error) { + input.c = c + input.u = c.URL + "/apps/" + url.PathEscape(input.AppID) + output = &UpdateAppOutput{} + err = c.Do(input, output) + return +} + +// DeleteAppInput models the input sent to DELETE /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/deleteApp +type DeleteAppInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required" cliutil:"option=app-id"` + Name string `json:"name" validate:"required" cliutil:"option=name usage='name of the app'"` +} + +func (i *DeleteAppInput) url() string { return i.u } +func (i *DeleteAppInput) method() string { return http.MethodDelete } +func (i *DeleteAppInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *DeleteAppInput) encode() ([]byte, error) { return marshalJSON(i) } + +// DeleteAppOutput models the output returned by DELETE /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/deleteApp +type DeleteAppOutput struct { + ErrorProperties + + ID string `json:"deletedAppId,omitempty"` +} + +func (o *DeleteAppOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// DeleteApp sends a request to DELETE /v1/apps/{appId}. +// See https://developer.quickbase.com/operation/deleteApp +func (c *Client) DeleteApp(input *DeleteAppInput) (output *DeleteAppOutput, err error) { + input.c = c + input.u = c.URL + "/apps/" + url.PathEscape(input.AppID) + output = &DeleteAppOutput{} + err = c.Do(input, output) + return +} + +// ListAppEventsInput models the input sent to GET /v1/apps/{appId}/events. +// See https://developer.quickbase.com/operation/getAppEvents +type ListAppEventsInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required" cliutil:"option=app-id"` +} + +func (i *ListAppEventsInput) url() string { return i.u } +func (i *ListAppEventsInput) method() string { return http.MethodGet } +func (i *ListAppEventsInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *ListAppEventsInput) encode() ([]byte, error) { return marshalJSON(i) } + +// ListAppEventsOutput models the output returned by GET /v1/apps/{appId}/events. +// See https://developer.quickbase.com/operation/getAppEvents +type ListAppEventsOutput struct { + ErrorProperties + + Events []*ListAppEventsOutputEvent `json:"events,omitempty"` +} + +func (o *ListAppEventsOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// UnmarshalJSON implements json.UnmarshalJSON by unmarshaling the payload into +// ListTablesOutput.Events. +func (o *ListAppEventsOutput) UnmarshalJSON(b []byte) (err error) { + var v []*ListAppEventsOutputEvent + if err = json.Unmarshal(b, &v); err == nil { + o.Events = v + } else { + err = json.Unmarshal(b, &o.ErrorProperties) + } + return +} + +// MarshalJSON implements json.MarshalJSON by marshaling output.Tables. +func (o *ListAppEventsOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(o.Events) +} + +// ListAppEventsOutputEvent models the event object. +type ListAppEventsOutputEvent struct { + Type string `json:"type"` + Owner *User `json:"owner"` + IsActive bool `json:"isActive"` + TableID string `json:"tableId"` + Name string `json:"name"` + URL string `json:"url,omitempty"` +} + +// ListAppEvents sends a request to GET /v1/apps/{appId}/events. +// See https://developer.quickbase.com/operation/getAppEvents +func (c *Client) ListAppEvents(input *ListAppEventsInput) (output *ListAppEventsOutput, err error) { + input.c = c + input.u = c.URL + "/apps/" + url.PathEscape(input.AppID) + "/events" + output = &ListAppEventsOutput{} + err = c.Do(input, output) + return +} + +// CopyAppInput models the input sent to POST /v1/apps/{appId}/copy. +// See https://developer.quickbase.com/operation/copyApp +type CopyAppInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required" cliutil:"option=app-id usage='unique identifier of an app'"` + Name string `json:"name" validate:"required" cliutil:"option=name usage='name of the app'"` + Description string `json:"description,omitempty" cliutil:"option=description usage='description of the app'"` + Properties *CopyAppInputProperties `json:"properties,omitempty"` +} + +func (i *CopyAppInput) url() string { return i.u } +func (i *CopyAppInput) method() string { return http.MethodPost } +func (i *CopyAppInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *CopyAppInput) encode() ([]byte, error) { return marshalJSON(i) } + +// CopyAppInputProperties models the properties property. +type CopyAppInputProperties struct { + AssignUserToken bool `json:"assignUserToken,omitempty" cliutil:"option=assign-token usage='assign the user token to the app'"` + ExcludeFiles bool `json:"excludeFiles,omitempty" cliutil:"option=exclude-files usage='exclude attached files if --copy-data is passed'"` + KeepData bool `json:"keepData,omitempty" cliutil:"option=keep-data usage='copy data'"` + KeepUsersAndRoles bool `json:"usersAndRoles,omitempty" cliutil:"option=keep-users-roles usage='copy users and roles'"` +} + +// CopyAppOutput models the output returned by POST /v1/apps/{appId}/copy. +// See https://developer.quickbase.com/operation/copyApp +type CopyAppOutput struct { + ErrorProperties + App + + AncestorID string `json:"ancestorId,omitempty"` +} + +func (o *CopyAppOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// CopyApp sends a request to POST /v1/apps/{appId}/copy. +// See https://developer.quickbase.com/operation/copyApp +func (c *Client) CopyApp(input *CopyAppInput) (output *CopyAppOutput, err error) { + input.c = c + input.u = c.URL + "/apps/" + url.PathEscape(input.AppID) + "/copy" + output = &CopyAppOutput{} + err = c.Do(input, output) + return +} diff --git a/qbclient/resource_field.go b/qbclient/resource_field.go new file mode 100644 index 0000000..4abbcc2 --- /dev/null +++ b/qbclient/resource_field.go @@ -0,0 +1,257 @@ +package qbclient + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "strconv" +) + +// ListFieldsInput models the input sent to GET /v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/getFields +type ListFieldsInput struct { + c *Client + u string + + TableID string `json:"-" validate:"required"` + IncludeFieldPermissions bool `json:"includeFieldPerms"` +} + +func (i *ListFieldsInput) url() string { return i.u } +func (i *ListFieldsInput) method() string { return http.MethodGet } +func (i *ListFieldsInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *ListFieldsInput) encode() ([]byte, error) { return marshalJSON(i) } + +// ListFieldsOutput models the output returned by GET /v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/getFields +type ListFieldsOutput struct { + ErrorProperties + + Fields []*ListFieldsOutputField `json:"fields,omitempty"` +} + +func (o *ListFieldsOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// UnmarshalJSON implements json.UnmarshalJSON by unmarshaling the payload into +// ListFieldsOutput.Fields. +func (o *ListFieldsOutput) UnmarshalJSON(b []byte) (err error) { + var v []*ListFieldsOutputField + if err = json.Unmarshal(b, &o.ErrorProperties); err == nil { + return + } + if err = json.Unmarshal(b, &v); err == nil { + o.Fields = v + } + return +} + +// ListFieldsOutputField models the field object. +type ListFieldsOutputField struct { + Field + FieldID int `json:"id,omitempty"` + Properties *ListFieldsOutputFieldProperties `json:"properties,omitempty"` +} + +// ListFieldsOutputFieldProperties models the field object properties. +type ListFieldsOutputFieldProperties struct { + FieldProperties +} + +// ListFields sends a request to GET /v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/getFields +func (c *Client) ListFields(input *ListFieldsInput) (output *ListFieldsOutput, err error) { + input.c = c + input.u = c.URL + "/fields?tableId=" + url.QueryEscape(input.TableID) + output = &ListFieldsOutput{} + err = c.Do(input, output) + return +} + +// ListFieldsByTableID sends a request to GET /v1/fields?tableId={tableId} an +// lists fields for the passed table. +// See https://developer.quickbase.com/operation/getFields +func (c *Client) ListFieldsByTableID(tableID string) (*ListFieldsOutput, error) { + return c.ListFields(&ListFieldsInput{TableID: tableID}) +} + +// CreateFieldInput models the input sent to POST /v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/createField +type CreateFieldInput struct { + Field + + c *Client + u string + + TableID string `json:"-" validate:"required"` + Properties *CreateFieldInputProperties `json:"properties,omitempty"` +} + +func (i *CreateFieldInput) url() string { return i.u } +func (i *CreateFieldInput) method() string { return http.MethodPost } +func (i *CreateFieldInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *CreateFieldInput) encode() ([]byte, error) { return marshalJSON(i) } + +// CreateFieldInputProperties models the "properties" property. +type CreateFieldInputProperties struct { + FieldProperties +} + +// CreateFieldOutput models the output returned by POST /v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/createField +type CreateFieldOutput struct { + ErrorProperties + Field + + FieldID int `json:"id"` + Properties *CreateFieldOutputProperties `json:"properties,omitempty"` +} + +func (o *CreateFieldOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// CreateFieldOutputProperties models the "properties" property. +type CreateFieldOutputProperties struct { + FieldProperties +} + +// CreateField sends a request to POST /v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/createField +func (c *Client) CreateField(input *CreateFieldInput) (output *CreateFieldOutput, err error) { + input.c = c + input.u = c.URL + "/fields?tableId=" + url.QueryEscape(input.TableID) + output = &CreateFieldOutput{} + err = c.Do(input, output) + return +} + +// DeleteFieldsInput models the input sent to DELETE v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/deleteFields +type DeleteFieldsInput struct { + c *Client + u string + + TableID string `json:"-" validate:"required"` + FieldIDs []int `json:"fieldIds" validate:"required,min=1"` +} + +func (i *DeleteFieldsInput) url() string { return i.u } +func (i *DeleteFieldsInput) method() string { return http.MethodDelete } +func (i *DeleteFieldsInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *DeleteFieldsInput) encode() ([]byte, error) { return marshalJSON(i) } + +// DeleteFieldsOutput models the output returned by DELETE v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/deleteFields +type DeleteFieldsOutput struct { + ErrorProperties + + DeletedFieldIDs []int `json:"deletedFieldIds,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +func (o *DeleteFieldsOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// DeleteFields sends a request to DELETE v1/fields?tableId={tableId}. +// See https://developer.quickbase.com/operation/deleteFields +func (c *Client) DeleteFields(input *DeleteFieldsInput) (output *DeleteFieldsOutput, err error) { + input.c = c + input.u = c.URL + "/fields?tableId=" + url.QueryEscape(input.TableID) + output = &DeleteFieldsOutput{} + err = c.Do(input, output) + return +} + +// GetFieldInput models the input sent to GET /v1/fields/{fieldId}?tableId={tableId}. +// See https://developer.quickbase.com/operation/getField +type GetFieldInput struct { + c *Client + u string + + TableID string `json:"-" validate:"required"` + FieldID int `json:"-" validate:"required"` +} + +func (i *GetFieldInput) url() string { return i.u } +func (i *GetFieldInput) method() string { return http.MethodGet } +func (i *GetFieldInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *GetFieldInput) encode() ([]byte, error) { return marshalJSON(i) } + +// GetFieldOutput models the output returned by GET /v1/fields/{fieldId}?tableId={tableId}. +// See https://developer.quickbase.com/operation/getField +type GetFieldOutput struct { + ErrorProperties + Field + + FieldID int `json:"id,omitempty"` + Properties *GetFieldOutputProperties `json:"properties,omitempty"` +} + +func (o *GetFieldOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// GetFieldOutputProperties models the "properties" property. +type GetFieldOutputProperties struct { + FieldProperties +} + +// GetField sends a request to GET /v1/fields/{fieldId}?tableId={tableId}. +// See https://developer.quickbase.com/operation/getField +func (c *Client) GetField(input *GetFieldInput) (output *GetFieldOutput, err error) { + input.c = c + fieldID := strconv.Itoa(input.FieldID) + input.u = c.URL + "/fields/" + url.PathEscape(fieldID) + "?tableId=" + url.QueryEscape(input.TableID) + output = &GetFieldOutput{} + err = c.Do(input, output) + return +} + +// GetFieldByID sends a request to GET /v1/fields/{fieldId}?tableId={tableId} +// and gets a field by its ID. +// See https://developer.quickbase.com/operation/getField +func (c *Client) GetFieldByID(fid int) (*GetFieldOutput, error) { + return c.GetField(&GetFieldInput{FieldID: fid}) +} + +// UpdateFieldInput models the input sent to POST /v1/fields/{fieldId}?tableId={tableId}. +// See https://developer.quickbase.com/operation/updateField +type UpdateFieldInput struct { + Field + + c *Client + u string + + TableID string `json:"-" validate:"required"` + FieldID int `json:"-" validate:"required"` + Properties *UpdateFieldInputProperties `json:"properties,omitempty"` +} + +func (i *UpdateFieldInput) url() string { return i.u } +func (i *UpdateFieldInput) method() string { return http.MethodPost } +func (i *UpdateFieldInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *UpdateFieldInput) encode() ([]byte, error) { return marshalJSON(i) } + +// UpdateFieldInputProperties models the "properties" property. +type UpdateFieldInputProperties struct { + FieldProperties +} + +// UpdateFieldOutput models the output returned by POST /v1/fields/{fieldId}?tableId={tableId}. +// See https://developer.quickbase.com/operation/updateField +type UpdateFieldOutput struct { + ErrorProperties + Field + + FieldID int `json:"id"` + Properties *CreateFieldOutputProperties `json:"properties,omitempty"` +} + +func (o *UpdateFieldOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// UpdateField sends a request to POST /v1/fields/{fieldId}?tableId={tableId}. +// See https://developer.quickbase.com/operation/updateField +func (c *Client) UpdateField(input *UpdateFieldInput) (output *UpdateFieldOutput, err error) { + input.c = c + fieldID := strconv.Itoa(input.FieldID) + input.u = c.URL + "/fields/" + url.PathEscape(fieldID) + "?tableId=" + url.QueryEscape(input.TableID) + output = &UpdateFieldOutput{} + err = c.Do(input, output) + return +} diff --git a/qbclient/resource_records.go b/qbclient/resource_records.go new file mode 100644 index 0000000..43a2cf4 --- /dev/null +++ b/qbclient/resource_records.go @@ -0,0 +1,163 @@ +package qbclient + +import ( + "io" + "net/http" +) + +// InsertRecordsInput models the input sent to POST /v1/records. +// See https://developer.quickbase.com/operation/upsert +type InsertRecordsInput struct { + c *Client + u string + + Data []map[int]*InsertRecordsInputData `json:"data" validate:"required,min=1"` + To string `json:"to" validate:"required"` + MergeFieldID int `json:"mergeFieldId,omitempty"` + FieldsToReturn []int `json:"fieldsToReturn,omitempty"` +} + +func (i *InsertRecordsInput) url() string { return i.u } +func (i *InsertRecordsInput) method() string { return http.MethodPost } +func (i *InsertRecordsInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *InsertRecordsInput) encode() ([]byte, error) { return marshalJSON(i) } + +// InsertRecordsInputData models the data property. +type InsertRecordsInputData struct { + Value *Value `json:"value" validate:"required"` +} + +// InsertRecordsOutput models the output returned by POST /v1/records. +// See https://developer.quickbase.com/operation/upsert +type InsertRecordsOutput struct { + ErrorProperties + + Metadata *InsertRecordsOutputMetadata `json:"metadata,omitempty"` +} + +func (o *InsertRecordsOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// InsertRecordsOutputMetadata models the metadata property. +type InsertRecordsOutputMetadata struct { + CreatedRecordIDs []int `json:"createdRecordIds"` + TotalNumberOfRecordsProcessed int `json:"totalNumberOfRecordsProcessed"` + UnchangedRecordIDs []int `json:"unchangedRecordIds"` + IpdatedRecordIDs []int `json:"updatedRecordIds"` +} + +// InsertRecords sends a request to POST /v1/records. +// See https://developer.quickbase.com/operation/upsert +func (c *Client) InsertRecords(input *InsertRecordsInput) (output *InsertRecordsOutput, err error) { + input.c = c + input.u = c.URL + "/records" + output = &InsertRecordsOutput{} + err = c.Do(input, output) + return +} + +// DeleteRecordsInput models the input sent to DELETE /v1/records. +// See https://developer.quickbase.com/operation/deleteRecords +type DeleteRecordsInput struct { + c *Client + u string + + From string `json:"from" validate:"required"` + Where string `json:"where" validate:"required"` +} + +func (i *DeleteRecordsInput) url() string { return i.u } +func (i *DeleteRecordsInput) method() string { return http.MethodDelete } +func (i *DeleteRecordsInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *DeleteRecordsInput) encode() ([]byte, error) { return marshalJSON(i) } + +// DeleteRecordsOutput models the output returned by DELETE /v1/records. +// See https://developer.quickbase.com/operation/deleteRecords +type DeleteRecordsOutput struct { + ErrorProperties + + NumberDeleted int `json:"numberDeleted,omitempty"` +} + +func (o *DeleteRecordsOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// DeleteRecords sends a request to DELETE /v1/records. +// See https://developer.quickbase.com/operation/deleteRecords +func (c *Client) DeleteRecords(input *DeleteRecordsInput) (output *DeleteRecordsOutput, err error) { + input.c = c + input.u = c.URL + "/records" + output = &DeleteRecordsOutput{} + err = c.Do(input, output) + return +} + +// QueryRecordsInput models the input sent to POST /v1/records/query. +// See https://developer.quickbase.com/operation/runQuery +type QueryRecordsInput struct { + c *Client + u string + + Select []int `json:"select" validate:"required,min=1"` + From string `json:"from" validate:"required"` + Where string `json:"where"` + GroupBy []*QueryRecordsInputGroupBy `json:"groupBy,omitempty"` + SortBy []*QueryRecordsInputSortBy `json:"sortBy,omitempty"` +} + +func (i *QueryRecordsInput) url() string { return i.u } +func (i *QueryRecordsInput) method() string { return http.MethodPost } +func (i *QueryRecordsInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *QueryRecordsInput) encode() ([]byte, error) { return marshalJSON(i) } + +// QueryRecordsInputGroupBy models the groupBy objects. +type QueryRecordsInputGroupBy struct { + FieldID int `json:"fieldId"` + Grouping string `json:"grouping"` +} + +// QueryRecordsInputSortBy models the sortBy objects. +type QueryRecordsInputSortBy struct { + FieldID int `json:"fieldId"` + Order string `json:"order"` +} + +// QueryRecordsOutput models the output returned by POST /v1/records/query. +// See https://developer.quickbase.com/operation/runQuery +type QueryRecordsOutput struct { + ErrorProperties + + Data []map[int]*QueryRecordsOutputData `json:"data,omitempty"` + Fields []*QueryRecordsOutputFields `json:"fields,omitempty"` + Metadata *QueryRecordsOutputMetadata `json:"metadata,omitempty"` +} + +func (o *QueryRecordsOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// QueryRecordsOutputData models objects in the data property. +type QueryRecordsOutputData struct { + Value *Value `json:"value"` +} + +// QueryRecordsOutputFields models the objects in the fields property. +type QueryRecordsOutputFields struct { + FieldID int `json:"id"` + Label string `json:"label"` + Type string `json:"type"` +} + +// QueryRecordsOutputMetadata models the metadata property. +type QueryRecordsOutputMetadata struct { + TotalRecords int `json:"totalRecords"` + NumRecords int `json:"numRecords"` + NumFields int `json:"numFields"` + Skip int `json:"skip"` +} + +// QueryRecords sends a request to POST /v1/records/query. +// See https://developer.quickbase.com/operation/runQuery +func (c *Client) QueryRecords(input *QueryRecordsInput) (output *QueryRecordsOutput, err error) { + input.c = c + input.u = c.URL + "/records/query" + output = &QueryRecordsOutput{} + err = c.Do(input, output) + return +} diff --git a/qbclient/resource_relationship.go b/qbclient/resource_relationship.go new file mode 100644 index 0000000..109b822 --- /dev/null +++ b/qbclient/resource_relationship.go @@ -0,0 +1,175 @@ +package qbclient + +import ( + "io" + "net/http" + "net/url" +) + +// ListRelationshipsInput models the input sent to GET /v1/tables/{tableId}/relationships. +// See https://developer.quickbase.com/operation/getRelationships +type ListRelationshipsInput struct { + c *Client + u string + + ChildTableID string `json:"-" validate:"required"` +} + +func (i *ListRelationshipsInput) url() string { return i.u } +func (i *ListRelationshipsInput) method() string { return http.MethodGet } +func (i *ListRelationshipsInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *ListRelationshipsInput) encode() ([]byte, error) { return marshalJSON(i) } + +// ListRelationshipsOutput models the output returned by GET /v1/tables/{tableId}/relationships. +// See https://developer.quickbase.com/operation/getRelationships +type ListRelationshipsOutput struct { + ErrorProperties + + Metadata *ListRelationshipsOutputMetadata `json:"metadata,omitempty"` + Relationships []*Relationship `json:"relationships,omitempty"` +} + +func (o *ListRelationshipsOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// ListRelationshipsOutputMetadata models the metadata property. +type ListRelationshipsOutputMetadata struct { + NumberOfRelationships int `json:"numRelationships,omitempty"` + Skip int `json:"skip,omitempty"` + TotalRelationships int `json:"totalRelationships,omitempty"` +} + +// GetRelationships sends a request to GET /v1/tables/{tableId}/relationships. +// See https://developer.quickbase.com/operation/getRelationships +func (c *Client) GetRelationships(input *ListRelationshipsInput) (output *ListRelationshipsOutput, err error) { + input.c = c + input.u = c.URL + "/tables/" + url.PathEscape(input.ChildTableID) + "/relationships" + output = &ListRelationshipsOutput{} + err = c.Do(input, output) + return +} + +// ListRelationshipsByTableID sends a request to GET /v1/tables/{tableId}/relationships +// and gets a relationship by table ID. +// See https://developer.quickbase.com/operation/getTable +func (c *Client) ListRelationshipsByTableID(id string) (*ListRelationshipsOutput, error) { + return c.GetRelationships(&ListRelationshipsInput{ChildTableID: id}) +} + +// CreateRelationshipInput models the input sent to POST /v1/tables/{tableId}/relationship. +// See https://developer.quickbase.com/operation/createRelationship +type CreateRelationshipInput struct { + c *Client + u string + + ChildTableID string `json:"-" validate:"required"` + ParentTableID string `json:"parentTableId,omitempty" validate:"required"` + ForeignKeyField *CreateRelationshipInputForeignKeyField `json:"foreignKeyField,omitempty"` + LookupFieldIDs []int `json:"lookupFieldIds,omitempty"` + SummaryFields []*RelationshipSummaryField `json:"summaryFields,omitempty"` +} + +func (i *CreateRelationshipInput) url() string { return i.u } +func (i *CreateRelationshipInput) method() string { return http.MethodPost } +func (i *CreateRelationshipInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *CreateRelationshipInput) encode() ([]byte, error) { return marshalJSON(i) } + +// CreateRelationshipInputForeignKeyField models the summaryFields property. +type CreateRelationshipInputForeignKeyField struct { + Label string `json:"label,omitempty"` +} + +// CreateRelationshipOutput models the output returned by POST /v1/tables/{tableId}/relationship. +// See https://developer.quickbase.com/operation/createRelationship +type CreateRelationshipOutput struct { + ErrorProperties + Relationship +} + +func (o *CreateRelationshipOutput) decode(body io.ReadCloser) error { + return unmarshalJSON(body, &o) +} + +// CreateRelationship sends a request to POST /v1/tables/{tableId}/relationship. +// See https://developer.quickbase.com/operation/createRelationship +func (c *Client) CreateRelationship(input *CreateRelationshipInput) (output *CreateRelationshipOutput, err error) { + input.c = c + input.u = c.URL + "/tables/" + url.PathEscape(input.ChildTableID) + "/relationship" + output = &CreateRelationshipOutput{} + err = c.Do(input, output) + return +} + +// UpdateRelationshipInput models the input sent to POST /v1/tables/{tableId}/relationship/{relationshipId}. +// See https://developer.quickbase.com/operation/updateRelationship +type UpdateRelationshipInput struct { + c *Client + u string + + ChildTableID string `json:"-" validate:"required"` + RelationshipID int `json:"-" validate:"required"` + LookupFieldIDs []int `json:"lookupFieldIds,omitempty"` + SummaryFields []*RelationshipSummaryField `json:"summaryFields,omitempty"` +} + +func (i *UpdateRelationshipInput) url() string { return i.u } +func (i *UpdateRelationshipInput) method() string { return http.MethodPost } +func (i *UpdateRelationshipInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *UpdateRelationshipInput) encode() ([]byte, error) { return marshalJSON(i) } + +// UpdateRelationshipOutput models the output returned by POST /v1/tables/{tableId}/relationship/{relationshipId}. +// See https://developer.quickbase.com/operation/updateRelationship +type UpdateRelationshipOutput struct { + ErrorProperties + Relationship +} + +func (o *UpdateRelationshipOutput) decode(body io.ReadCloser) error { + return unmarshalJSON(body, &o) +} + +// UpdateRelationship sends a request to POST /v1/tables/{tableId}/relationship/{relationshipId}. +// See https://developer.quickbase.com/operation/updateRelationship +func (c *Client) UpdateRelationship(input *UpdateRelationshipInput) (output *UpdateRelationshipOutput, err error) { + input.c = c + input.u = c.URL + relationshipPath(input.ChildTableID, input.RelationshipID) + output = &UpdateRelationshipOutput{} + err = c.Do(input, output) + return +} + +// DeleteRelationshipInput models the input sent to DELETE /v1/tables/{tableId}/relationship/{relationshipId} +// See https://developer.quickbase.com/operation/deleteRelationship +type DeleteRelationshipInput struct { + c *Client + u string + + ChildTableID string `json:"-" validate:"required"` + RelationshipID int `json:"-" validate:"required"` +} + +func (i *DeleteRelationshipInput) url() string { return i.u } +func (i *DeleteRelationshipInput) method() string { return http.MethodDelete } +func (i *DeleteRelationshipInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *DeleteRelationshipInput) encode() ([]byte, error) { return marshalJSON(i) } + +// DeleteRelationshipOutput models the output returned by DELETE /v1/tables/{tableId}/relationship/{relationshipId} +// See https://developer.quickbase.com/operation/deleteRelationship +type DeleteRelationshipOutput struct { + ErrorProperties + + RelationshipID int `json:"relationshipId,omitempty"` +} + +func (o *DeleteRelationshipOutput) decode(body io.ReadCloser) error { + return unmarshalJSON(body, &o) +} + +// DeleteRelationship sends a request to DELETE /v1/tables/{tableId}/relationship/{relationshipId} +// See https://developer.quickbase.com/operation/deleteRelationship +func (c *Client) DeleteRelationship(input *DeleteRelationshipInput) (output *DeleteRelationshipOutput, err error) { + input.c = c + input.u = c.URL + relationshipPath(input.ChildTableID, input.RelationshipID) + output = &DeleteRelationshipOutput{} + err = c.Do(input, output) + return +} diff --git a/qbclient/resource_tables.go b/qbclient/resource_tables.go new file mode 100644 index 0000000..2899d9b --- /dev/null +++ b/qbclient/resource_tables.go @@ -0,0 +1,266 @@ +package qbclient + +import ( + "encoding/json" + "io" + "net/http" + "net/url" +) + +// CreateTableInput models the input sent to POST /v1/tables?appId={appId}. +// See https://developer.quickbase.com/operation/createTable +type CreateTableInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required"` + Name string `json:"name" validate:"required"` + Description string `json:"description"` + IconName string `json:"iconName"` + SingularNoun string `json:"singularNoun"` + PluralNoun string `json:"pluralNoun"` +} + +func (i *CreateTableInput) url() string { return i.u } +func (i *CreateTableInput) method() string { return http.MethodPost } +func (i *CreateTableInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *CreateTableInput) encode() ([]byte, error) { return marshalJSON(i) } + +// CreateTableOutput models the output returned by POST /v1/tables?appId={appId}. +// See https://developer.quickbase.com/operation/createTable +type CreateTableOutput struct { + ErrorProperties + + TableID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + IconName string `json:"iconName,omitempty"` + SingularNoun string `json:"singularNoun,omitempty"` + PluralNoun string `json:"pluralNoun,omitempty"` +} + +func (o *CreateTableOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// CreateTable sends a request to POST /v1/tables?appId={appId}. +// See https://developer.quickbase.com/operation/createTable +func (c *Client) CreateTable(input *CreateTableInput) (output *CreateTableOutput, err error) { + input.c = c + input.u = c.URL + "/tables?appId=" + url.QueryEscape(input.AppID) + output = &CreateTableOutput{} + err = c.Do(input, output) + return +} + +// ListTablesInput models the input sent to GET /v1/tables?appId={appId}. +// See https://developer.quickbase.com/operation/getAppTables +type ListTablesInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required"` +} + +func (i *ListTablesInput) url() string { return i.u } +func (i *ListTablesInput) method() string { return http.MethodGet } +func (i *ListTablesInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *ListTablesInput) encode() ([]byte, error) { return marshalJSON(i) } + +// ListTablesOutput models the input sent to GET /v1/tables?appId={appId}. +// See https://developer.quickbase.com/operation/getAppTables +type ListTablesOutput struct { + ErrorProperties + + Tables []*ListTablesOutputTable `json:"tables,omitempty"` +} + +func (o *ListTablesOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// UnmarshalJSON implements json.UnmarshalJSON by unmarshaling the payload into +// ListTablesOutput.Tables. +func (o *ListTablesOutput) UnmarshalJSON(b []byte) (err error) { + var v []*ListTablesOutputTable + if err = json.Unmarshal(b, &v); err == nil { + o.Tables = v + } else { + err = json.Unmarshal(b, &o.ErrorProperties) + } + return +} + +// MarshalJSON implements json.MarshalJSON by marshaling output.Tables. +func (o *ListTablesOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(o.Tables) +} + +// ListTablesOutputTable models the table object. +type ListTablesOutputTable struct { + Name string `json:"name"` + TableID string `json:"id"` + Alias string `json:"alias"` + Description string `json:"description"` + Created *Timestamp `json:"created"` + Updated *Timestamp `json:"updated"` + NextRecordID int `json:"nextRecordId"` + NextFieldID int `json:"nextFieldId"` + DefaultSortFieldID int `json:"defaultSortFieldId"` + DefaultSortOrder string `json:"defaultSortOrder"` + KeyFieldID int `json:"keyFieldId"` + SingleRecordName string `json:"singleRecordName"` + PluralRecordName string `json:"pluralRecordName"` + TimeZone string `json:"timeZone"` + DateFormat string `json:"dateFormat"` + SizeLimit string `json:"sizeLimit"` + SpaceRemaining string `json:"spaceRemaining"` + SpaceUsed string `json:"spaceUsed"` +} + +// ListTables sends a request to GET /v1/tables?appId={appId}. +// See https://developer.quickbase.com/operation/getAppTables +func (c *Client) ListTables(input *ListTablesInput) (output *ListTablesOutput, err error) { + input.c = c + input.u = c.URL + "/tables?appId=" + url.QueryEscape(input.AppID) + output = &ListTablesOutput{} + err = c.Do(input, output) + return +} + +// ListTablesByAppID sends a request to GET /v1/tables?appId={appId} and gets a +// list of tables in an app by its ID. +// See https://developer.quickbase.com/operation/getAppTables +func (c *Client) ListTablesByAppID(id string) (*ListTablesOutput, error) { + return c.ListTables(&ListTablesInput{AppID: id}) +} + +// GetTableInput models the input sent to GET /v1/tables/{tableId}. +// See https://developer.quickbase.com/operation/getTable +type GetTableInput struct { + c *Client + u string + + TableID string `json:"-" validate:"required"` +} + +func (i *GetTableInput) url() string { return i.u } +func (i *GetTableInput) method() string { return http.MethodGet } +func (i *GetTableInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *GetTableInput) encode() ([]byte, error) { return marshalJSON(i) } + +// GetTableOutput models the output returned by GET /v1/tables/{tableId}. +// See https://developer.quickbase.com/operation/getTable +type GetTableOutput struct { + ErrorProperties + + Name string `json:"name,omitempty"` + TableID string `json:"id,omitempty"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + Created *Timestamp `json:"created,omitempty"` + Updated *Timestamp `json:"updated,omitempty"` + NextRecordID int `json:"nextRecordId,omitempty"` + NextFieldID int `json:"nextFieldId,omitempty"` + DefaultSortFieldID int `json:"defaultSortFieldId,omitempty"` + DefaultSortOrder string `json:"defaultSortOrder,omitempty"` + KeyFieldID int `json:"keyFieldId,omitempty"` + SingleRecordName string `json:"singleRecordName,omitempty"` + PluralRecordName string `json:"pluralRecordName,omitempty"` + TimeZone string `json:"timeZone,omitempty"` + DateFormat string `json:"dateFormat,omitempty"` +} + +func (o *GetTableOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// GetTable sends a request to GET /v1/tables/{tableId}. +// See https://developer.quickbase.com/operation/getTable +func (c *Client) GetTable(input *GetTableInput) (output *GetTableOutput, err error) { + input.c = c + input.u = c.URL + "/tables/" + url.PathEscape(input.TableID) + output = &GetTableOutput{} + err = c.Do(input, output) + return +} + +// GetTableByID sends a request to GET /v1/tables/{tableId} and gets a table ID. +// See https://developer.quickbase.com/operation/getTable +func (c *Client) GetTableByID(id string) (*GetTableOutput, error) { + return c.GetTable(&GetTableInput{TableID: id}) +} + +// UpdateTableInput models the input sent to POST /v1/tables/{tableId}?appId={appId}. +// See https://developer.quickbase.com/operation/updateTable +type UpdateTableInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required"` + TableID string `json:"-" validate:"required"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + IconName string `json:"iconName,omitempty"` + SingularNoun string `json:"singularNoun,omitempty"` + PluralNoun string `json:"pluralNoun,omitempty"` +} + +func (i *UpdateTableInput) url() string { return i.u } +func (i *UpdateTableInput) method() string { return http.MethodPost } +func (i *UpdateTableInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *UpdateTableInput) encode() ([]byte, error) { return marshalJSON(i) } + +// UpdateTableOutput models the output returned by POST /v1/tables/{tableId}?appId={appId}. +// See https://developer.quickbase.com/operation/updateTable +type UpdateTableOutput struct { + ErrorProperties + + TableID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + IconName string `json:"iconName,omitempty"` + SingularNoun string `json:"singularNoun,omitempty"` + PluralNoun string `json:"pluralNoun,omitempty"` +} + +func (o *UpdateTableOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// UpdateTable sends a request to POST /v1/tables/{tableId}?appId={appId}. +// See https://developer.quickbase.com/operation/updateTable +func (c *Client) UpdateTable(input *UpdateTableInput) (output *UpdateTableOutput, err error) { + input.c = c + input.u = c.URL + "/tables/" + url.PathEscape(input.TableID) + "?appId=" + url.QueryEscape(input.AppID) + output = &UpdateTableOutput{} + err = c.Do(input, output) + return +} + +// DeleteTableInput models the input sent to DELETE /v1/tables/{tableId}?appId={appId}. +// See https://developer.quickbase.com/operation/deleteTable +type DeleteTableInput struct { + c *Client + u string + + AppID string `json:"-" validate:"required"` + TableID string `json:"-" validate:"required"` +} + +func (i *DeleteTableInput) url() string { return i.u } +func (i *DeleteTableInput) method() string { return http.MethodDelete } +func (i *DeleteTableInput) addHeaders(req *http.Request) { addHeadersJSON(req, i.c) } +func (i *DeleteTableInput) encode() ([]byte, error) { return marshalJSON(i) } + +// DeleteTableOutput models the output returned by DELETE /v1/tables/{tableId}?appId={appId}. +// See https://developer.quickbase.com/operation/deleteTable +type DeleteTableOutput struct { + ErrorProperties + + TableID string `json:"deletedTableId,omitempty"` +} + +func (o *DeleteTableOutput) decode(body io.ReadCloser) error { return unmarshalJSON(body, &o) } + +// DeleteTable sends a request to DELETE /v1/tables/{tableId}?appId={appId}. +// See https://developer.quickbase.com/operation/deleteTable +func (c *Client) DeleteTable(input *DeleteTableInput) (output *DeleteTableOutput, err error) { + input.c = c + input.u = c.URL + "/tables/" + url.PathEscape(input.TableID) + "?appId=" + url.QueryEscape(input.AppID) + output = &DeleteTableOutput{} + err = c.Do(input, output) + return +} diff --git a/qbclient/security.go b/qbclient/security.go new file mode 100644 index 0000000..a16daf5 --- /dev/null +++ b/qbclient/security.go @@ -0,0 +1,19 @@ +package qbclient + +import "regexp" + +var reUserTokenMask *regexp.Regexp + +// MaskUserToken masks user tokens in a byte slice. +func MaskUserToken(b []byte) []byte { + return reUserTokenMask.ReplaceAll(b, []byte(`${1}_${2}********************${3}`)) +} + +// MaskUserTokenString masks user tokens in a string. +func MaskUserTokenString(s string) string { + return reUserTokenMask.ReplaceAllString(s, "${1}_${2}********************${3}") +} + +func init() { + reUserTokenMask = regexp.MustCompile(`([0-9a-z]+_[0-9a-z]+)_([0-9a-z]{4})[0-9a-z]+([0-9a-z]{4})`) +} diff --git a/qbclient/utils.go b/qbclient/utils.go new file mode 100644 index 0000000..75c63fa --- /dev/null +++ b/qbclient/utils.go @@ -0,0 +1,12 @@ +package qbclient + +import ( + "regexp" + "strings" +) + +// TableAlias converts a label to a table alias. +func TableAlias(label string) string { + re := regexp.MustCompile(`\W`) + return strings.ToUpper("_DBID_" + re.ReplaceAllString(label, "_")) +} diff --git a/qbclient/utils_test.go b/qbclient/utils_test.go new file mode 100644 index 0000000..ee1d038 --- /dev/null +++ b/qbclient/utils_test.go @@ -0,0 +1,23 @@ +package qbclient_test + +import ( + "testing" + + "github.com/QuickBase/quickbase-cli/qbclient" +) + +func TestTableAlias(t *testing.T) { + tables := []struct { + label string + want string + }{ + {"Table [WITH] some -chars", "_DBID_TABLE__WITH__SOME__CHARS"}, + } + + for _, tt := range tables { + have := qbclient.TableAlias(tt.label) + if have != tt.want { + t.Errorf("have %q, want %q", have, tt.want) + } + } +} diff --git a/qbclient/validate.go b/qbclient/validate.go new file mode 100644 index 0000000..5309134 --- /dev/null +++ b/qbclient/validate.go @@ -0,0 +1,43 @@ +package qbclient + +import ( + "errors" + "fmt" + "regexp" +) + +var ( + reHostname *regexp.Regexp +) + +func init() { + reHostname = regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`) +} + +// ValidateStringFn validates a string. +type ValidateStringFn func(string) error + +// NoValidation always returns a nil error. +func NoValidation(s string) error { + return nil +} + +// ValidateNotEmptyFn returns a function that validates a string isn't empty. +func ValidateNotEmptyFn(label string) ValidateStringFn { + return func(s string) (err error) { + if s == "" { + err = fmt.Errorf("%s required", label) + } + return + } +} + +// ValidateHostname validates the passed hostname. +func ValidateHostname(hostname string) error { + if hostname == "" { + return errors.New("hostname required") + } else if !reHostname.MatchString(hostname) { + return errors.New("invalid hostname, expecting format example.quickbase.com") + } + return nil +} diff --git a/qberrors/README.md b/qberrors/README.md new file mode 100644 index 0000000..09fab1c --- /dev/null +++ b/qberrors/README.md @@ -0,0 +1,83 @@ +# qberrors + +The `qberrors` package provides consistent error handling for Quickbase applications written in Go. + +## Goals + +* Simplify code by sticking to [common Go conventions](https://blog.golang.org/go1.13-errors) with transparent abstractions for conveninence. +* Improve security by separating internal errors from those that are safe for the user to see. +* Improve user experience by returning appropriate HTTP status codes with detailed error messages. +* Simplify internal logging by maintaining the entire error chain through the edge of the app. +* Promote resiliency by recommending whether the operation that caused the error should be retried. + +## Usage + +* `Client` errors are the result of user input and should not be retried until the input is changed. +* `Internal` errors are internal to the application and should not be retried until code is fixed. +* `Service` errors are temporary problems that should be retried using a backoff algorithm. + +Standard errors are treated as `Internal` and unsafe for users to see. + +```go +err := errors.New("something bad happened") + +fmt.Printf("%t\n", qberrors.IsSafe(err)) +// Output: false + +fmt.Println(qberrors.SafeMessage(err)) +// Output: internal error + +fmt.Println(qberrors.Upstream(err)) +// Output: something bad happened + +fmt.Println(qberrors.StatusCode(err)) +// Output: 500 +``` + +`Service` errors imply that the operation should be retried. The example below displays a helpful message to the user while maintaining the internal error chain for logging: + +```go +connect := func() error { + return errors.New("timeout connecting to service") +} + +cerr := connect() +werr := fmt.Errorf("additional context: %w", cerr) + +time := time.Now().Add(5 * time.Minute).Format("3:04 PM") // A time 5 minutes from now. +err := qberrors.Service(werr).Safef(qberrors.ServceUnavailable, "please retry at %s", time) + +fmt.Println(err) +// Output: please retry at 11:19 AM: service unavailable + +fmt.Println(qberrors.SafeMessage(err)) +// Output: service unavailable + +fmt.Println(qberrors.SafeDetail(err)) +// Output: please retry at 11:19 AM + +fmt.Println(qberrors.Upstream(err)) +// Output: additional context: timeout connecting to service + +fmt.Println(qberrors.StatusCode(err)) +// Output: 503 +``` + +Handling "not found" errors is common in applications. This library treats them as `Client` errors so that developers can use Go's error handling capabilities to control the logic of the application and show users a helpful message with an appropriate status code. + +```go +id := "123" +err := qberrors.NotFoundError("item %q", id) + +fmt.Printf("%t\n", errors.Is(err, qberrors.NotFound)) +// Output: true + +fmt.Println(err) +// Output: item "123": not found + +fmt.Println(qberrors.Upstream(err)) +// Output: + +fmt.Println(qberrors.StatusCode(err)) +// Output: 404 +``` \ No newline at end of file diff --git a/qberrors/errors.go b/qberrors/errors.go new file mode 100644 index 0000000..779bfe7 --- /dev/null +++ b/qberrors/errors.go @@ -0,0 +1,151 @@ +// Package qberrors provides common errors to standardize error handling across +// Quickbae golang projects. +// +// See https://blog.golang.org/go1.13-errors +package qberrors + +import ( + "fmt" + "net/http" +) + +const ( + defaultMsg = "internal error" + defaultCode = http.StatusInternalServerError +) + +// ErrSafe errors. +var ( + BadRequest = ErrSafe{"bad request", http.StatusBadRequest} + InvalidDataType = ErrSafe{"data type not valid", http.StatusBadRequest} + InvalidInput = ErrSafe{"input not valid", http.StatusBadRequest} + InvalidSyntax = ErrSafe{"syntax not valid", http.StatusBadRequest} + NotFound = ErrSafe{"not found", http.StatusNotFound} + ServceUnavailable = ErrSafe{"service unavailable", http.StatusServiceUnavailable} +) + +// ErrSafe is an error that is assumed to be safe to show to the user. Errors +// that wrap ErrSafe are also assumed to be safe to show the user, inclusive of +// all subsequent wraps up the chain. +type ErrSafe struct { + Message string + StatusCode int +} + +func (e ErrSafe) Error() string { return e.Message } + +// SafeErrorf returns a wrapped ErrSafe given the format specifier. +func SafeErrorf(err error, format string, a ...interface{}) error { + format += ": %w" + a = append(a, err) + return fmt.Errorf(format, a...) +} + +// Error is implemented by errors. +type Error interface { + + // Upstream returns the error chain that caused showing the user an error. + // It is assumed that the error chain is unsafe to show the user. + Upstream() error + + // Retry returns whether the operation should be retried. + Retry() bool +} + +// ErrClient is an error due to client input. +// The operation should not be retried. +type ErrClient struct { + upstream error + safe error +} + +// Client returns an ErrClient. +func Client(err error) *ErrClient { return &ErrClient{err, BadRequest} } + +func (e ErrClient) Unwrap() error { return e.safe } +func (e ErrClient) Error() string { return e.safe.Error() } + +// Upstream implements Error.Upstream. +func (e ErrClient) Upstream() error { return e.upstream } + +// Retry implements Error.Retry. +func (e ErrClient) Retry() bool { return false } + +// Safe sets ErrClient.safe as err. +// TODO add variadic argument that wraps err +func (e *ErrClient) Safe(err error) error { + e.safe = err + return e +} + +// Safef sets ErrClient.safe as a wrapped err. +func (e *ErrClient) Safef(err error, format string, a ...interface{}) error { + e.safe = SafeErrorf(err, format, a...) + return e +} + +// ErrInternal is an error with the application. +// The operation should not be retried. +type ErrInternal struct { + upstream error + safe error +} + +// Internal returns an ErrInternal. +func Internal(err error) *ErrInternal { + return &ErrInternal{err, ErrSafe{defaultMsg, defaultCode}} +} + +func (e ErrInternal) Unwrap() error { return e.safe } +func (e ErrInternal) Error() string { return e.safe.Error() } + +// Upstream implements Error.Upstream. +func (e ErrInternal) Upstream() error { return e.upstream } + +// Retry implements Error.Retry. +func (e ErrInternal) Retry() bool { return false } + +// Safe sets ErrInternal.safe as err. +// TODO add variadic argument that wraps err +func (e *ErrInternal) Safe(err error) error { + e.safe = err + return e +} + +// Safef sets ErrInternal.safe as a wrapped err. +func (e *ErrInternal) Safef(err error, format string, a ...interface{}) error { + e.safe = SafeErrorf(err, format, a...) + return e +} + +// ErrService is an error connecting to a dependent service. +// The operation can be retried. +type ErrService struct { + upstream error + safe error +} + +// Service returns an ErrService. +func Service(err error) *ErrService { return &ErrService{err, ServceUnavailable} } + +func (e ErrService) Unwrap() error { return e.safe } +func (e ErrService) Error() string { return e.safe.Error() } + +// Upstream implements Error.Upstream. +func (e ErrService) Upstream() error { return e.upstream } + +// Retry implements Error.Retry. +func (e ErrService) Retry() bool { return true } + +// Safe sets ErrService.safe as err. +// TODO add variadic argument that wraps err +func (e *ErrService) Safe(err error) error { + e.safe = err + return e +} + +// Safef sets ErrService.safe as a wrapped err. +func (e *ErrService) Safef(err error, format string, a ...interface{}) error { + e.safe = SafeErrorf(err, format, a...) + return e +} diff --git a/qberrors/errors_test.go b/qberrors/errors_test.go new file mode 100644 index 0000000..81debbf --- /dev/null +++ b/qberrors/errors_test.go @@ -0,0 +1,83 @@ +package qberrors_test + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/QuickBase/quickbase-cli/qberrors" +) + +func TestErrors(t *testing.T) { + up3 := errors.New("upstream") + up4 := errors.New("unsafe") + + err1 := qberrors.SafeErrorf(qberrors.NotFound, "item %q", "123") + err2 := fmt.Errorf("more context: %w", err1) + err3 := fmt.Errorf("context: %w", qberrors.Internal(up3)) + err4 := up4 + err5 := qberrors.ErrSafe{Message: "test default status code"} + + tests := []struct { + err error + exIsSafe bool + exStatusCode int + exMessage string + exDetail string + exUpstream error + }{ + {err1, true, http.StatusNotFound, "not found", `item "123"`, nil}, + {err2, true, http.StatusNotFound, "not found", `more context: item "123"`, nil}, + {err3, true, http.StatusInternalServerError, "internal error", "context", up3}, + {err4, false, http.StatusInternalServerError, "internal error", "", up4}, + {err5, true, http.StatusInternalServerError, "test default status code", "", nil}, + } + + for _, tt := range tests { + if actual := qberrors.IsSafe(tt.err); actual != tt.exIsSafe { + t.Errorf("expected qberrors.IsSafe to return %t", tt.exIsSafe) + } + if actual := qberrors.StatusCode(tt.err); tt.exStatusCode != actual { + t.Errorf("got %v, expected %v", actual, tt.exStatusCode) + } + if actual := qberrors.SafeMessage(tt.err); tt.exMessage != actual { + t.Errorf("got %q, expected %q", actual, tt.exMessage) + } + if actual := qberrors.SafeDetail(tt.err); tt.exDetail != actual { + t.Errorf("got %q, expected %q", actual, tt.exDetail) + } + if actual := qberrors.Upstream(tt.err); tt.exUpstream != actual { + t.Errorf("got %q, expected %q", actual, tt.exUpstream) + } + } +} + +func TestError(t *testing.T) { + err := qberrors.Client(nil).Safe(qberrors.NotFound) + + if !errors.Is(err, qberrors.NotFound) { + t.Error("expected errors.Is to be true") + } + + if actual := err.Error(); actual != qberrors.NotFound.Message { + t.Errorf("got %q, expected %q", actual, qberrors.NotFound.Message) + } +} + +func TestRetry(t *testing.T) { + tests := []struct { + err qberrors.Error + exRetry bool + }{ + {qberrors.Client(nil), false}, + {qberrors.Internal(nil), false}, + {qberrors.Service(nil), true}, + } + + for _, tt := range tests { + if actual := tt.err.Retry(); actual != tt.exRetry { + t.Errorf("got %t, expected %t", actual, tt.exRetry) + } + } +} diff --git a/qberrors/handlers.go b/qberrors/handlers.go new file mode 100644 index 0000000..97d4923 --- /dev/null +++ b/qberrors/handlers.go @@ -0,0 +1,34 @@ +package qberrors + +import ( + "encoding/json" + "net/http" +) + +// HandleErrorJSON handles a JSON unmarshaling error for input passed by a user +// by normalizing messages and returning either a ErrClient or ErrInternal. +func HandleErrorJSON(err error) error { + if serr, ok := err.(*json.SyntaxError); ok { + return Client(serr).Safef(BadRequest, "%s: offset %v", serr, serr.Offset) + } + + if terr, ok := err.(*json.UnmarshalTypeError); ok { + return Client(terr).Safef( + BadRequest, + "%s field expects %s value, %s passed: offset %v", + terr.Field, + terr.Type, + terr.Value, + terr.Offset, + ) + } + + serr := ErrSafe{"internal error decoding json", http.StatusInternalServerError} + return Internal(err).Safe(serr) +} + +// HandleErrorValidation handles github.com/go-playground/validator validation +// errors for input passed by a user and returns an ErrClient. +func HandleErrorValidation(err error) error { + return Client(nil).Safef(BadRequest, "input not valid: %s", err) +} diff --git a/qberrors/util.go b/qberrors/util.go new file mode 100644 index 0000000..483ac3e --- /dev/null +++ b/qberrors/util.go @@ -0,0 +1,72 @@ +package qberrors + +import ( + "errors" + "strings" +) + +// IsSafe returns true if err is safe to show the user. +func IsSafe(err error) bool { return errors.As(err, &ErrSafe{}) } + +// SafeMessage returns an error message that is safe for the user to see. +func SafeMessage(err error) string { + if IsSafe(err) { + if serr, ok := err.(ErrSafe); ok { + return serr.Message + } + return SafeMessage(errors.Unwrap(err)) + } + return defaultMsg +} + +// SafeDetail returns detail about the error that is safe for the user to see. +func SafeDetail(err error) string { + if IsSafe(err) { + s := err.Error() + m := SafeMessage(err) + return strings.TrimRight(s[0:len(s)-len(m)], ": ") + } + return "" +} + +// StatusCode returns the status code associated with the error. +func StatusCode(err error) int { + if serr, ok := err.(ErrSafe); ok { + if serr.StatusCode != 0 { + return serr.StatusCode + } + } else if err != nil { + return StatusCode(errors.Unwrap(err)) + } + return defaultCode +} + +// Upstream returns the error chain that caused the user to be shown an error. +// The error chain is assumed to be unsafe for the user to see. +func Upstream(err error) error { + if IsSafe(err) { + return upstream(err) + } + return err +} + +func upstream(err error) error { + if ierr, ok := err.(Error); ok { + return ierr.Upstream() + } else if werr := errors.Unwrap(err); werr != nil { + return upstream(werr) + } + return nil +} + +// NotFoundError returns an ErrClient with ErrClient.safe set as NotFound and +// additional context according to the format specifier. +func NotFoundError(format string, a ...interface{}) error { + return Client(nil).Safef(NotFound, format, a...) +} + +// InvalidInputError returns an ErrClient with ErrClient.safe set as BadRequest +// and additional context according to the format specifier. +func InvalidInputError(err error, format string, a ...interface{}) error { + return Client(err).Safef(BadRequest, format, a...) +} diff --git a/qberrors/util_test.go b/qberrors/util_test.go new file mode 100644 index 0000000..b731edf --- /dev/null +++ b/qberrors/util_test.go @@ -0,0 +1,18 @@ +package qberrors_test + +import ( + "errors" + "fmt" + + "github.com/QuickBase/quickbase-cli/qberrors" +) + +func ExampleNotFoundError() { + err := qberrors.NotFoundError("item %q", "123") + + if errors.Is(err, qberrors.NotFound) { + fmt.Println(err) + } + + // Output: item "123": not found +}