Skip to content

Commit

Permalink
catalog-importer import
Browse files Browse the repository at this point in the history
For those who want to import an existing file of data or do a one-time
import, this command can help achieve that.

I've tested it against a CSV export of a Notion database. I asked the
import to actually run a sync but it's equally valid not to, and to rely
on the import command to generate the importer.jsonnet config that you
add to your broader sync config and use whatever source files you run
the import against to be the source of the data from thereon out.

The command I was using is this:

```
$ catalog-importer import \
  --local-file tmp/documents.csv \
  --name Documents \
  --description 'Documents from a Notion database' \
  --type-name 'Custom["Documents"]' \
  --source-external-id 'Name' \
  --source-name 'Name' \
  --run-sync \
  --run-sync-dry-run
```

This commit includes two broader changes which are:
- Setting the top-level scope of a CEL expression to the underscore
  character
- Providing support for CSV input files
  • Loading branch information
lawrencejones committed Jul 24, 2023
1 parent 6b8cdcc commit 1fc8a8a
Show file tree
Hide file tree
Showing 7 changed files with 237 additions and 15 deletions.
6 changes: 6 additions & 0 deletions cmd/catalog-importer/cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ var (
initCmd = app.Command("init", "Initialises a new config from a template")
initOptions = new(InitOptions).Bind(initCmd)

// import
importCmd = app.Command("import", "Import catalog data directly or generate importer config")
importOptions = new(ImportOptions).Bind(importCmd)

// Types
typesCmd = app.Command("types", "Shows all the types that can be used for this account")
typesOptions = new(TypesOptions).Bind(typesCmd)
Expand Down Expand Up @@ -88,6 +92,8 @@ func Run(ctx context.Context) (err error) {
switch command {
case initCmd.FullCommand():
return initOptions.Run(ctx, logger)
case importCmd.FullCommand():
return importOptions.Run(ctx, logger)
case typesCmd.FullCommand():
return typesOptions.Run(ctx, logger)
case sync.FullCommand():
Expand Down
153 changes: 153 additions & 0 deletions cmd/catalog-importer/cmd/import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"reflect"

"github.com/alecthomas/kingpin/v2"
kitlog "github.com/go-kit/kit/log"
"github.com/incident-io/catalog-importer/config"
"github.com/incident-io/catalog-importer/output"
"github.com/incident-io/catalog-importer/source"
"github.com/pkg/errors"
"github.com/samber/lo"
"gopkg.in/guregu/null.v3"
)

type ImportOptions struct {
APIEndpoint string
APIKey string
RunSync bool
RunSyncDryRun bool
Files []string
Name string
Description string
TypeName string
SourceExternalID string
SourceName string
}

func (opt *ImportOptions) Bind(cmd *kingpin.CmdClause) *ImportOptions {
cmd.Flag("api-endpoint", "Endpoint of the incident.io API").
Default("https://api.incident.io").
Envar("INCIDENT_ENDPOINT").
StringVar(&opt.APIEndpoint)
cmd.Flag("api-key", "API key for incident.io").
Envar("INCIDENT_API_KEY").
StringVar(&opt.APIKey)
cmd.Flag("run-sync", "Actually run the sync using the config produced by the import").
BoolVar(&opt.RunSync)
cmd.Flag("run-sync-dry-run", "If --run-sync, whether to do so in dry-run").
BoolVar(&opt.RunSyncDryRun)
cmd.Flag("local-file", "Which files to read content from, compatible with the local source").
Required().
StringsVar(&opt.Files)
cmd.Flag("name", "What to name the resulting catalog type e.g Devices").
Required().
StringVar(&opt.Name)
cmd.Flag("description", "What should be the description for the resulting catalog type").
Required().
StringVar(&opt.Description)
cmd.Flag("type-name", `What to give as a type name for the resulting catalog type e.g. Custom["Devices"]`).
Required().
StringVar(&opt.TypeName)
cmd.Flag("source-external-id", "What field of each source entry should be used as an external ID").
Required().
StringVar(&opt.SourceExternalID)
cmd.Flag("source-name", "What field of each source entry should be used as an entry name").
Required().
StringVar(&opt.SourceName)

return opt
}

func (opt *ImportOptions) Run(ctx context.Context, logger kitlog.Logger) error {
src := &source.SourceLocal{
Files: opt.Files,
}

logger.Log("msg", "loading entries from files", "files", opt.Files)
sourceEntries, err := src.Load(ctx, logger)
if err != nil {
return errors.Wrap(err, "reading source files")
}

entries := []source.Entry{}
for _, sourceEntry := range sourceEntries {
parsedEntries, err := sourceEntry.Parse()
if err != nil {
continue // quietly skip
}

entries = append(entries, parsedEntries...)
}

attributes := map[string]*output.Attribute{}
for _, entry := range entries {
for key, value := range entry {
_, alreadyAdded := attributes[key]
if alreadyAdded {
continue
}

escapedKey, _ := json.Marshal(key)

attributes[key] = &output.Attribute{
ID: key,
Name: key,
Type: null.StringFrom("String"),
Array: reflect.TypeOf(value).Kind() == reflect.Slice,
Source: null.StringFrom(fmt.Sprintf("_[%s]", string(escapedKey))),
}
}
}

cfg := config.Config{
SyncID: "one-time-import",
Pipelines: []*config.Pipeline{
{
Sources: []*source.Source{
{
Local: src,
},
},
Outputs: []*output.Output{
{
Name: opt.Name,
Description: opt.Description,
TypeName: opt.TypeName,
Source: output.SourceConfig{
Name: opt.SourceName,
ExternalID: opt.SourceExternalID,
},
Attributes: lo.Values(attributes),
},
},
},
},
}

BANNER("Pipeline that will import this file printed below")
output, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return errors.Wrap(err, "marshalling config")
}

fmt.Println(string(output))

if opt.RunSync {
syncOpt := *syncOptions
syncOpt.APIEndpoint = opt.APIEndpoint
syncOpt.APIKey = opt.APIKey
syncOpt.AllowDeleteAll = true
syncOpt.DryRun = opt.RunSyncDryRun

if err := syncOpt.Run(ctx, logger, &cfg); err != nil {
return errors.Wrap(err, "running sync")
}
}

return nil
}
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func init() {
}

type Config struct {
SyncID string `json:"sync_id"`
SyncID string `json:"sync_id,omitempty"`
Pipelines []*Pipeline `json:"pipelines"`
}

Expand Down
7 changes: 7 additions & 0 deletions expr/expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ func Compile(source string) (cel.Program, error) {
// Eval evaluates the given program against the scope, returning a value that matches the
// type requested via the generic ReturnType parameter.
func Eval[ReturnType any](ctx context.Context, prg cel.Program, scope map[string]any) (result ReturnType, err error) {
// It's useful to be able to refer directly to the top-level scope via a non-escaped
// variable name, as it's otherwise impossible to access a scope variable that has
// whitespace (e.g. "Name of thing").
//
// By adding a reference at _, this becomes possible via `_["Name of thing"]`.
scope["_"] = scope

out, _, err := prg.ContextEval(ctx, scope)
if err != nil {
return
Expand Down
15 changes: 1 addition & 14 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ github.com/go-kit/log v0.2.0 h1:7i2K3eKTos3Vc0enKCfnVcgHh2olr/MyfboYq7cAcFw=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
Expand Down Expand Up @@ -131,12 +129,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU=
github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/onsi/gomega v1.27.8 h1:gegWiwZjBsf2DgiSbf5hpokZ98JVDMcWkUiigk6/KXc=
github.com/onsi/gomega v1.27.8/go.mod h1:2J8vzI/s+2shY9XHRApDkdgPo1TKT7P2u6fXeJKFnNQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
Expand Down Expand Up @@ -186,17 +180,14 @@ golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA=
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
Expand All @@ -212,8 +203,6 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
Expand All @@ -226,8 +215,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
36 changes: 36 additions & 0 deletions source/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package source

import (
"bytes"
"encoding/csv"
"strings"
"unicode"

"github.com/google/go-jsonnet"
"gopkg.in/yaml.v2"
Expand Down Expand Up @@ -51,5 +54,38 @@ func Parse(filename string, data []byte) []Entry {
}
}

// If we find nothing, we'll attempt CSV as a hail-mary.
if len(entries) == 0 {
records, err := csv.NewReader(bytes.NewReader(data)).ReadAll()
if err != nil {
return entries
}

// We can only use CSVs that provide a header row. And if there only exists headers,
// we should return no entries.
if len(records) <= 1 {
return entries
}

headers, rows := records[0], records[1:]
headerIndexes := map[int]string{}
for idx, header := range headers {
headerStripped := strings.TrimFunc(header, func(r rune) bool {
return !unicode.IsGraphic(r)
})

headerIndexes[idx] = headerStripped
}

for _, row := range rows {
entry := Entry{}
for idx, column := range row {
entry[headerIndexes[idx]] = column
}

entries = append(entries, entry)
}
}

return entries
}
33 changes: 33 additions & 0 deletions source/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,37 @@ we: hate yaml
})
})
})

When("CSV", func() {
When("headers", func() {
BeforeEach(func() {
input = `
id,name,description
P123,My name is,What
P124,My name is,Who
P125,My name is,Slim Shady
`
})

It("returns all parsed entries", func() {
Expect(entries).To(Equal([]source.Entry{
{
"id": "P123",
"name": "My name is",
"description": "What",
},
{
"id": "P124",
"name": "My name is",
"description": "Who",
},
{
"id": "P125",
"name": "My name is",
"description": "Slim Shady",
},
}))
})
})
})
})

0 comments on commit 1fc8a8a

Please sign in to comment.