Skip to content

Commit

Permalink
feat(merge): add a merge command (#13)
Browse files Browse the repository at this point in the history
the cli-command "merge" merges multiple decK files into one.
  • Loading branch information
Tieske authored Mar 31, 2023
1 parent 1ab34b0 commit 5a00512
Show file tree
Hide file tree
Showing 16 changed files with 579 additions and 14 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dist/

# generated test files
convertoas3/oas3_testfiles/*.generated.json
merge/merge_testfiles/*_generated.json

# the binary
kced
Expand Down
20 changes: 20 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "main.go",
"args": ["merge",
"merge/merge_testfiles/file1.yml",
"merge/merge_testfiles/file2.yml",
"merge/merge_testfiles/file3.yml"
]
}
]
}
66 changes: 66 additions & 0 deletions cmd/merge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
Copyright © 2023 NAME HERE <EMAIL ADDRESS>
*/
package cmd

import (
"fmt"
"log"

"github.com/kong/go-apiops/filebasics"
"github.com/kong/go-apiops/merge"
"github.com/spf13/cobra"
)

// Executes the CLI command "openapi2kong"
func executeMerge(cmd *cobra.Command, args []string) {
outputFilename, err := cmd.Flags().GetString("output-file")
if err != nil {
log.Fatalf(fmt.Sprintf("failed getting cli argument 'output-file'; %%w"), err)
}

var asYaml bool
{
outputFormat, err := cmd.Flags().GetString("format")
if err != nil {
log.Fatalf(fmt.Sprintf("failed getting cli argument 'format'; %%w"), err)
}
if outputFormat == "yaml" {
asYaml = true
} else if outputFormat == "json" {
asYaml = false
} else {
log.Fatalf("expected '--format' to be either 'yaml' or 'json', got: '%s'", outputFormat)
}
}

// do the work: read/merge
filebasics.MustWriteSerializedFile(outputFilename, merge.MustFiles(args), asYaml)
}

//
//
// Define the CLI data for the openapi2kong command
//
//

var mergeCmd = &cobra.Command{
Use: "merge [flags] filename [...filename]",
Short: "Merges multiple decK files into one",
Long: `Merges multiple decK files into one.
The files can be either json or yaml format. Will merge all top-level arrays by simply
concatenating them. Any other keys will be copied. The files will be processed in the order
provided. No checks on content will be done, eg. duplicates, nor any validations.
If the input files are not compatible an error will be returned. Compatibility is
determined by the '_transform' and '_format_version' fields.`,
Run: executeMerge,
Args: cobra.MinimumNArgs(1),
}

func init() {
rootCmd.AddCommand(mergeCmd)
mergeCmd.Flags().StringP("output-file", "o", "-", "output file to write. Use - to write to stdout")
mergeCmd.Flags().StringP("format", "", "yaml", "output format: json or yaml")
}
4 changes: 2 additions & 2 deletions cmd/openapi2kong.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

// Executes the CLI command "openapi2kong"
func execute(cmd *cobra.Command, _ []string) {
func executeOpenapi2Kong(cmd *cobra.Command, _ []string) {
inputFilename, err := cmd.Flags().GetString("state")
if err != nil {
log.Fatalf(fmt.Sprintf("failed getting cli argument 'spec'; %%w"), err)
Expand Down Expand Up @@ -80,7 +80,7 @@ var openapi2kongCmd = &cobra.Command{
The example file has extensive annotations explaining the conversion
process, as well as all supported custom annotations (x-kong-... directives).
See: https://github.com/Kong/kced/blob/main/docs/learnservice_oas.yaml`,
Run: execute,
Run: executeOpenapi2Kong,
}

func init() {
Expand Down
134 changes: 134 additions & 0 deletions deckformat/deckformat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package deckformat

import (
"errors"
"fmt"
"strconv"
"strings"

"github.com/kong/go-apiops/jsonbasics"
)

const (
VersionKey = "_format_version"
TransformKey = "_transform"
)

// CompatibleTransform checks if 2 files are compatible, by '_transform' keys.
// Returns nil if compatible, and error otherwise.
func CompatibleTransform(data1 map[string]interface{}, data2 map[string]interface{}) error {
if data1 == nil {
panic("expected 'data1' to be non-nil")
}
if data2 == nil {
panic("expected 'data2' to be non-nil")
}

transform1 := true // this is the default value
if data1[TransformKey] != nil {
var err error
if transform1, err = jsonbasics.GetBoolField(data1, TransformKey); err != nil {
return err
}
}
transform2 := true // this is the default value
if data2[TransformKey] != nil {
var err error
if transform2, err = jsonbasics.GetBoolField(data2, TransformKey); err != nil {
return err
}
}

if transform1 != transform2 {
return errors.New("files with '_transform: true' (default) and '_transform: false' are not compatible")
}

return nil
}

// CompatibleVersion checks if 2 files are compatible, by '_format_version'. Version is compatible
// if they are the same major. Missing versions are assumed to be compatible.
// Returns nil if compatible, and error otherwise.
func CompatibleVersion(data1 map[string]interface{}, data2 map[string]interface{}) error {
if data1 == nil {
panic("expected 'data1' to be non-nil")
}
if data2 == nil {
panic("expected 'data2' to be non-nil")
}

if data1[VersionKey] == nil {
if data2[VersionKey] == nil {
return nil // neither given , so assume compatible
}
// data1 omitted, just validate data2 has a proper version, any version will do
_, _, err := ParseFormatVersion(data2)
return err
}

// data1 has a version
if data2[VersionKey] == nil {
// data2 omitted, just validate data1 has a proper version, any version will do
_, _, err := ParseFormatVersion(data1)
return err
}

// both versions given, go parse them
major1, minor1, err1 := ParseFormatVersion(data1)
if err1 != nil {
return err1
}
major2, minor2, err2 := ParseFormatVersion(data2)
if err2 != nil {
return err2
}

if major1 != major2 {
return fmt.Errorf("major versions are incompatible; %d.%d and %d.%d", major1, minor1, major2, minor2)
}

return nil
}

// CompatibleFile returns nil if the files are compatible. An error otherwise.
// see CompatibleVersion and CompatibleTransform for what compatibility means.
func CompatibleFile(data1 map[string]interface{}, data2 map[string]interface{}) error {
err := CompatibleTransform(data1, data2)
if err != nil {
return fmt.Errorf("files are incompatible; %w", err)
}
err = CompatibleVersion(data1, data2)
if err != nil {
return fmt.Errorf("files are incompatible; %w", err)
}
return nil
}

// parseFormatVersion parses field `_format_version` and returns major+minor.
// Field must be present, a string, and have an 'x.y' format. Returns an error otherwise.
func ParseFormatVersion(data map[string]interface{}) (int, int, error) {
// get the file version and check it
v, err := jsonbasics.GetStringField(data, VersionKey)
if err != nil {
return 0, 0, errors.New("expected field '._format_version' to be a string in 'x.y' format")
}
elem := strings.Split(v, ".")
if len(elem) > 2 {
return 0, 0, errors.New("expected field '._format_version' to be a string in 'x.y' format")
}

majorVersion, err := strconv.Atoi(elem[0])
if err != nil {
return 0, 0, errors.New("expected field '._format_version' to be a string in 'x.y' format")
}

minorVersion := 0
if len(elem) > 1 {
minorVersion, err = strconv.Atoi(elem[1])
if err != nil {
return 0, 0, errors.New("expected field '._format_version' to be a string in 'x.y' format")
}
}

return majorVersion, minorVersion, nil
}
55 changes: 45 additions & 10 deletions filebasics/filebasics.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package filebasics

import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
Expand All @@ -14,9 +15,9 @@ const (
defaultJSONIndent = " "
)

// MustReadFile reads file contents. Will panic if reading fails.
// ReadFile reads file contents.
// Reads from stdin if filename == "-"
func MustReadFile(filename string) *[]byte {
func ReadFile(filename string) (*[]byte, error) {
var (
body []byte
err error
Expand All @@ -28,10 +29,21 @@ func MustReadFile(filename string) *[]byte {
body, err = os.ReadFile(filename)
}

if err != nil {
return nil, err
}
return &body, nil
}

// MustReadFile reads file contents. Will panic if reading fails.
// Reads from stdin if filename == "-"
func MustReadFile(filename string) *[]byte {
body, err := ReadFile(filename)
if err != nil {
log.Fatalf("unable to read file: %v", err)
}
return &body

return body
}

// MustWriteFile writes the output to a file. Will panic if writing fails.
Expand Down Expand Up @@ -80,26 +92,35 @@ func MustSerialize(content map[string]interface{}, asYaml bool) *[]byte {
return &str
}

// MustDeserialize will deserialize data as a JSON or YAML object. Will panic
// if deserializing fails or if it isn't an object. Will never return nil.
func MustDeserialize(data *[]byte) map[string]interface{} {
// Deserialize will deserialize data as a JSON or YAML object. Will return an error
// if deserializing fails or if it isn't an object.
func Deserialize(data *[]byte) (map[string]interface{}, error) {
var output interface{}

err1 := json.Unmarshal(*data, &output)
if err1 != nil {
err2 := yaml.Unmarshal(*data, &output)
if err2 != nil {
log.Fatal("failed deserializing data as JSON (%w) and as YAML (%w)", err1, err2)
return nil, errors.New("failed deserializing data as JSON and as YAML")
}
}

switch output := output.(type) {
case map[string]interface{}:
return output
return output, nil
}

log.Fatal("Expected the data to be an Object")
return nil // will never happen, unreachable.
return nil, errors.New("expected the data to be an Object")
}

// MustDeserialize will deserialize data as a JSON or YAML object. Will panic
// if deserializing fails or if it isn't an object. Will never return nil.
func MustDeserialize(data *[]byte) map[string]interface{} {
jsondata, err := Deserialize(data)
if err != nil {
log.Fatal("%w", err)
}
return jsondata
}

// MustWriteSerializedFile will serialize the data and write it to a file. Will
Expand All @@ -108,6 +129,20 @@ func MustWriteSerializedFile(filename string, content map[string]interface{}, as
MustWriteFile(filename, MustSerialize(content, asYaml))
}

// DeserializeFile will read a JSON or YAML file and return the top-level object. Will return an
// error if it fails reading or the content isn't an object. Reads from stdin if filename == "-".
func DeserializeFile(filename string) (map[string]interface{}, error) {
bytedata, err := ReadFile(filename)
if err != nil {
return nil, err
}
data, err := Deserialize(bytedata)
if err != nil {
return nil, err
}
return data, nil
}

// MustDeserializeFile will read a JSON or YAML file and return the top-level object. Will
// panic if it fails reading or the content isn't an object. Reads from stdin if filename == "-".
// This will never return nil.
Expand Down
6 changes: 4 additions & 2 deletions jsonbasics/jsonbasics.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ func GetObjectArrayField(object map[string]interface{}, fieldName string) ([]map
result := make([]map[string]interface{}, 0, len(arr))
j := 0
for _, expectedObject := range arr {
service, err := ToObject(expectedObject)
obj, err := ToObject(expectedObject)
if err == nil {
result[j] = service
result[j] = obj
j++
}
}
Expand Down Expand Up @@ -126,6 +126,8 @@ func GetStringIndex(arr []interface{}, index int) (string, error) {
return "", fmt.Errorf("expected index '%d' to be a string, got %t", index, value)
}

// GetBoolField returns a boolean from an object field. Returns an error if the field
// is not a boolean, or is not found.
func GetBoolField(object map[string]interface{}, fieldName string) (bool, error) {
value := object[fieldName]
switch result := value.(type) {
Expand Down
Loading

0 comments on commit 5a00512

Please sign in to comment.