diff --git a/cmd/definition.go b/cmd/definition.go index ba7c1baa..4e8669c8 100644 --- a/cmd/definition.go +++ b/cmd/definition.go @@ -50,13 +50,12 @@ func NewCmdDefinition() *cobra.Command { } definitionPublishCmd := &cobra.Command{ - Use: "publish", + Use: "publish [definition file]", Short: "Publish an artifact definition to Massdriver", Long: helpdocs.MustRender("definition/publish"), + Args: cobra.ExactArgs(1), RunE: runDefinitionPublish, } - definitionPublishCmd.Flags().StringP("file", "f", "", "File containing artifact definition schema (use - for stdin)") - _ = definitionPublishCmd.MarkFlagRequired("file") definitionDeleteCmd := &cobra.Command{ Use: "delete [definition]", @@ -117,23 +116,9 @@ func runDefinitionGet(cmd *cobra.Command, args []string) error { func runDefinitionPublish(cmd *cobra.Command, args []string) error { ctx := context.Background() - defPath, err := cmd.Flags().GetString("file") - if err != nil { - return err - } + defFile := args[0] cmd.SilenceUsage = true - var defFile *os.File - if defPath == "-" { - defFile = os.Stdin - } else { - defFile, err = os.Open(defPath) - if err != nil { - fmt.Println(err) - } - defer defFile.Close() - } - mdClient, mdClientErr := client.New() if mdClientErr != nil { return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) diff --git a/cmd/schema.go b/cmd/schema.go index 15897951..b9008508 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -7,7 +7,7 @@ import ( "path/filepath" "github.com/massdriver-cloud/mass/docs/helpdocs" - "github.com/massdriver-cloud/mass/pkg/bundle" + "github.com/massdriver-cloud/mass/pkg/definition" "github.com/massdriver-cloud/mass/pkg/jsonschema" "github.com/massdriver-cloud/mass/pkg/prettylogs" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" @@ -68,11 +68,11 @@ func runSchemaDereference(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) } - derefOpts := bundle.DereferenceOptions{ + derefOpts := definition.DereferenceOptions{ Client: mdClient, Cwd: basePath, } - dereferencedSchema, derefErr := bundle.DereferenceSchema(rawSchema, derefOpts) + dereferencedSchema, derefErr := definition.DereferenceSchema(rawSchema, derefOpts) if derefErr != nil { return fmt.Errorf("failed to dereference schema: %w", derefErr) } diff --git a/docs/generated/mass_definition_publish.md b/docs/generated/mass_definition_publish.md index c39090c1..47c9dc4b 100644 --- a/docs/generated/mass_definition_publish.md +++ b/docs/generated/mass_definition_publish.md @@ -12,38 +12,33 @@ Publish an artifact definition to Massdriver # Publish Artifact Definition -Publishes a new or updated artifact definition to Massdriver. +Publishes a new or updated artifact definition to Massdriver. Supports JSON or YAML formats. ## Usage ```bash -mass definition publish --file +mass definition publish ``` ## Examples ```bash -# Publish a definition from a file -mass definition publish --file my-definition.json +# Publish a definition from a JSON file +mass definition publish my-definition.json -# Publish a definition from stdin -cat my-definition.json | mass definition publish --file - +# Publish a definition from a YAML file +mass definition publish my-definition.yaml ``` -## Options - -- `--file`: Path to the definition file (use - for stdin) - ``` -mass definition publish [flags] +mass definition publish [definition file] [flags] ``` ### Options ``` - -f, --file string File containing artifact definition schema (use - for stdin) - -h, --help help for publish + -h, --help help for publish ``` ### SEE ALSO diff --git a/docs/helpdocs/definition/publish.md b/docs/helpdocs/definition/publish.md index dc849523..3f1c6a37 100644 --- a/docs/helpdocs/definition/publish.md +++ b/docs/helpdocs/definition/publish.md @@ -1,23 +1,19 @@ # Publish Artifact Definition -Publishes a new or updated artifact definition to Massdriver. +Publishes a new or updated artifact definition to Massdriver. Supports JSON or YAML formats. ## Usage ```bash -mass definition publish --file +mass definition publish ``` ## Examples ```bash -# Publish a definition from a file -mass definition publish --file my-definition.json +# Publish a definition from a JSON file +mass definition publish my-definition.json -# Publish a definition from stdin -cat my-definition.json | mass definition publish --file - +# Publish a definition from a YAML file +mass definition publish my-definition.yaml ``` - -## Options - -- `--file`: Path to the definition file (use - for stdin) diff --git a/pkg/bundle/dereference.go b/pkg/bundle/dereference.go index 46f75775..88233505 100644 --- a/pkg/bundle/dereference.go +++ b/pkg/bundle/dereference.go @@ -1,16 +1,8 @@ package bundle import ( - "context" - "encoding/json" - "errors" "fmt" - "io" - "net/http" - "os" "path/filepath" - "reflect" - "regexp" "github.com/massdriver-cloud/mass/pkg/definition" @@ -37,7 +29,7 @@ func (b *Bundle) DereferenceSchemas(path string, mdClient *client.Client) error } } - dereferencedSchema, err := DereferenceSchema(*task.schema, DereferenceOptions{Client: mdClient, Cwd: cwd}) + dereferencedSchema, err := definition.DereferenceSchema(*task.schema, definition.DereferenceOptions{Client: mdClient, Cwd: cwd}) if err != nil { return err @@ -53,202 +45,3 @@ func (b *Bundle) DereferenceSchemas(path string, mdClient *client.Client) error return nil } - -type DereferenceOptions struct { - Client *client.Client - Cwd string -} - -// relativeFilePathPattern only accepts relative file path prefixes "./" and "../" -var relativeFilePathPattern = regexp.MustCompile(`^(\.\/|\.\.\/)`) -var massdriverDefinitionPattern = regexp.MustCompile(`^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?$`) -var httpPattern = regexp.MustCompile(`^(http|https)://`) -var fragmentPattern = regexp.MustCompile(`^#`) - -func DereferenceSchema(anyVal any, opts DereferenceOptions) (any, error) { - val := getValue(anyVal) - - switch val.Kind() { //nolint:exhaustive - case reflect.Slice, reflect.Array: - return dereferenceList(val, opts) - case reflect.Map: - schemaInterface := val.Interface() - schema, ok := schemaInterface.(map[string]any) - if !ok { - return nil, fmt.Errorf("schema is not an object") - } - hydratedSchema := map[string]any{} - - // if this part of the schema has a $ref that is a local file, read it and make it - // the map that we hydrate into. This causes any keys in the ref'ing object to override anything in the ref'd object - // which adheres to the JSON Schema spec. - if schemaRefInterface, refOk := schema["$ref"]; refOk { - schemaRefValue, refStringOk := schemaRefInterface.(string) - if !refStringOk { - return nil, fmt.Errorf("$ref is not a string") - } - - var err error - if relativeFilePathPattern.MatchString(schemaRefValue) { //nolint:gocritic - // this is a relative file ref - // build up the path from where the dir current schema was read - hydratedSchema, err = dereferenceFilePathRef(hydratedSchema, schema, schemaRefValue, opts) - } else if httpPattern.MatchString(schemaRefValue) { - // HTTP ref. Pull the schema down via HTTP GET and hydrate - hydratedSchema, err = dereferenceHTTPRef(hydratedSchema, schema, schemaRefValue, opts) - } else if massdriverDefinitionPattern.MatchString(schemaRefValue) { - // this must be a published schema, so fetch from massdriver - hydratedSchema, err = dereferenceMassdriverRef(hydratedSchema, schema, schemaRefValue, opts) - } else if fragmentPattern.MatchString(schemaRefValue) { - // this is a fragment, so we do nothing and leave the schema as is - // since fragments are not dereferenced in the same way as full schemas - } else { - return nil, fmt.Errorf("unable to resolve ref: %s", schemaRefValue) - } - if err != nil { - return hydratedSchema, err - } - } - return dereferenceMap(hydratedSchema, schema, opts) - default: - return anyVal, nil - } -} - -func dereferenceMap(hydratedSchema map[string]any, schema map[string]any, opts DereferenceOptions) (map[string]any, error) { - for key, value := range schema { - var valueInterface = value - hydratedValue, err := DereferenceSchema(valueInterface, opts) - if err != nil { - return hydratedSchema, err - } - hydratedSchema[key] = hydratedValue - } - return hydratedSchema, nil -} - -func dereferenceList(val reflect.Value, opts DereferenceOptions) ([]any, error) { - hydratedList := make([]any, 0) - for i := 0; i < val.Len(); i++ { - hydratedVal, err := DereferenceSchema(val.Index(i).Interface(), opts) - if err != nil { - return hydratedList, err - } - hydratedList = append(hydratedList, hydratedVal) - } - return hydratedList, nil -} - -func dereferenceMassdriverRef(hydratedSchema map[string]any, schema map[string]any, schemaRefValue string, opts DereferenceOptions) (map[string]any, error) { - referencedSchema, err := definition.GetAsMap(context.Background(), opts.Client, schemaRefValue) - if err != nil { - return hydratedSchema, err - } - - if nestedSchema, exists := referencedSchema["schema"]; exists { - var ok bool - referencedSchema, ok = nestedSchema.(map[string]any) - if !ok { - return hydratedSchema, fmt.Errorf("schema is not a map") - } - } - - hydratedSchema, err = replaceRef(schema, referencedSchema, opts) - if err != nil { - return hydratedSchema, err - } - return hydratedSchema, nil -} - -func dereferenceHTTPRef(hydratedSchema map[string]any, schema map[string]any, schemaRefValue string, opts DereferenceOptions) (map[string]any, error) { - ctx := context.Background() - var referencedSchema map[string]any - - client := http.Client{} - request, err := http.NewRequestWithContext(ctx, http.MethodGet, schemaRefValue, nil) - if err != nil { - return hydratedSchema, err - } - resp, doErr := client.Do(request) - if doErr != nil { - return hydratedSchema, doErr - } - if resp.StatusCode != http.StatusOK { - return hydratedSchema, errors.New("received non-200 response getting ref " + resp.Status + " " + schemaRefValue) - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return hydratedSchema, err - } - err = json.Unmarshal(body, &referencedSchema) - if err != nil { - return hydratedSchema, err - } - - hydratedSchema, err = replaceRef(schema, referencedSchema, opts) - return hydratedSchema, err -} - -func dereferenceFilePathRef(hydratedSchema map[string]any, schema map[string]any, schemaRefValue string, opts DereferenceOptions) (map[string]any, error) { - var referencedSchema map[string]any - var schemaRefDir string - schemaRefAbsPath, err := filepath.Abs(filepath.Join(opts.Cwd, schemaRefValue)) - - if err != nil { - return hydratedSchema, err - } - - schemaRefDir = filepath.Dir(schemaRefAbsPath) - referencedSchema, readErr := readJSONFile(schemaRefAbsPath) - - if readErr != nil { - return hydratedSchema, readErr - } - - var replaceErr error - opts.Cwd = schemaRefDir - hydratedSchema, replaceErr = replaceRef(schema, referencedSchema, opts) - if replaceErr != nil { - return hydratedSchema, replaceErr - } - return hydratedSchema, nil -} - -func getValue(anyVal any) reflect.Value { - val := reflect.ValueOf(anyVal) - - if val.Kind() == reflect.Ptr { - val = val.Elem() - } - - return val -} - -func readJSONFile(filepath string) (map[string]any, error) { - var result map[string]any - data, err := os.ReadFile(filepath) - - if err != nil { - return result, err - } - err = json.Unmarshal(data, &result) - - return result, err -} - -func replaceRef(base map[string]any, referenced map[string]any, opts DereferenceOptions) (map[string]any, error) { - hydratedSchema := map[string]any{} - delete(base, "$ref") - - for k, v := range referenced { - hydratedValue, err := DereferenceSchema(v, opts) - if err != nil { - return hydratedSchema, err - } - hydratedSchema[k] = hydratedValue - } - return hydratedSchema, nil -} diff --git a/pkg/definition/delete.go b/pkg/definition/delete.go new file mode 100644 index 00000000..44ac08c5 --- /dev/null +++ b/pkg/definition/delete.go @@ -0,0 +1,44 @@ +package definition + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/massdriver-cloud/mass/pkg/api" + "github.com/massdriver-cloud/mass/pkg/prettylogs" + + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" +) + +func Delete(ctx context.Context, mdClient *client.Client, definitionName string, force bool) error { + // Get definition details for confirmation + ad, getErr := Get(ctx, mdClient, definitionName) + if getErr != nil { + return fmt.Errorf("error getting artifact definition: %w", getErr) + } + + // Prompt for confirmation - requires typing the definition name unless --force is used + if !force { + fmt.Printf("WARNING: This will permanently delete artifact definition `%s`.\n", ad.Name) + fmt.Printf("Type `%s` to confirm deletion: ", ad.Name) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(answer) + + if answer != ad.Name { + fmt.Println("Deletion cancelled.") + return nil + } + } + + deletedDef, deleteErr := api.DeleteArtifactDefinition(ctx, mdClient, definitionName) + if deleteErr != nil { + return fmt.Errorf("error deleting artifact definition: %w", deleteErr) + } + + fmt.Printf("Artifact definition %s deleted successfully!\n", prettylogs.Underline(deletedDef.Name)) + return nil +} diff --git a/pkg/definition/delete_test.go b/pkg/definition/delete_test.go new file mode 100644 index 00000000..ef0546a3 --- /dev/null +++ b/pkg/definition/delete_test.go @@ -0,0 +1,68 @@ +package definition_test + +import ( + "strings" + "testing" + + "github.com/massdriver-cloud/mass/pkg/definition" + "github.com/massdriver-cloud/mass/pkg/gqlmock" + + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +func TestDelete(t *testing.T) { + type test struct { + name string + defName string + response map[string]any + wantID string + wantName string + force bool + expectErr bool + errMessage string + } + tests := []test{ + { + name: "simple", + defName: "aws-s3", + response: map[string]any{ + "id": "123-456", + "name": "massdriver/test-schema", + }, + wantID: "def-123", + wantName: "org-123/aws-s3", + force: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + responses := []any{ + gqlmock.MockQueryResponse("getArtifactDefinition", tc.response), + gqlmock.MockMutationResponse("deleteArtifactDefinition", tc.response), + } + + mdClient := client.Client{ + GQL: gqlmock.NewClientWithJSONResponseArray(responses), + Config: config.Config{ + OrganizationID: "org-123", + }, + } + + err := definition.Delete(t.Context(), &mdClient, tc.defName, tc.force) + if tc.expectErr { + if err == nil { + t.Fatalf("expected error but got none") + } + if !strings.Contains(err.Error(), tc.errMessage) { + t.Fatalf("expected error message to contain %q but got %q", tc.errMessage, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/pkg/definition/dereference.go b/pkg/definition/dereference.go new file mode 100644 index 00000000..71e7fef6 --- /dev/null +++ b/pkg/definition/dereference.go @@ -0,0 +1,215 @@ +package definition + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "regexp" + + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" +) + +type DereferenceOptions struct { + Client *client.Client + Cwd string +} + +// relativeFilePathPattern only accepts relative file path prefixes "./" and "../" +var relativeFilePathPattern = regexp.MustCompile(`^(\.\/|\.\.\/)`) +var massdriverDefinitionPattern = regexp.MustCompile(`^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?$`) +var httpPattern = regexp.MustCompile(`^(http|https)://`) +var fragmentPattern = regexp.MustCompile(`^#`) + +func DereferenceSchema(anyVal any, opts DereferenceOptions) (any, error) { + val := getValue(anyVal) + + switch val.Kind() { //nolint:exhaustive + case reflect.Slice, reflect.Array: + return dereferenceList(val, opts) + case reflect.Map: + schemaInterface := val.Interface() + schema, ok := schemaInterface.(map[string]any) + if !ok { + return nil, fmt.Errorf("schema is not an object") + } + hydratedSchema := map[string]any{} + + // if this part of the schema has a $ref that is a local file, read it and make it + // the map that we hydrate into. This causes any keys in the ref'ing object to override anything in the ref'd object + // which adheres to the JSON Schema spec. + if schemaRefInterface, refOk := schema["$ref"]; refOk { + schemaRefValue, refStringOk := schemaRefInterface.(string) + if !refStringOk { + return nil, fmt.Errorf("$ref is not a string") + } + + var err error + if relativeFilePathPattern.MatchString(schemaRefValue) { //nolint:gocritic + // this is a relative file ref + // build up the path from where the dir current schema was read + hydratedSchema, err = dereferenceFilePathRef(hydratedSchema, schema, schemaRefValue, opts) + } else if httpPattern.MatchString(schemaRefValue) { + // HTTP ref. Pull the schema down via HTTP GET and hydrate + hydratedSchema, err = dereferenceHTTPRef(hydratedSchema, schema, schemaRefValue, opts) + } else if massdriverDefinitionPattern.MatchString(schemaRefValue) { + // this must be a published schema, so fetch from massdriver + hydratedSchema, err = dereferenceMassdriverRef(hydratedSchema, schema, schemaRefValue, opts) + } else if fragmentPattern.MatchString(schemaRefValue) { + // this is a fragment, so we do nothing and leave the schema as is + // since fragments are not dereferenced in the same way as full schemas + } else { + return nil, fmt.Errorf("unable to resolve ref: %s", schemaRefValue) + } + if err != nil { + return hydratedSchema, err + } + } + return dereferenceMap(hydratedSchema, schema, opts) + default: + return anyVal, nil + } +} + +func dereferenceMap(hydratedSchema map[string]any, schema map[string]any, opts DereferenceOptions) (map[string]any, error) { + for key, value := range schema { + var valueInterface = value + hydratedValue, err := DereferenceSchema(valueInterface, opts) + if err != nil { + return hydratedSchema, err + } + hydratedSchema[key] = hydratedValue + } + return hydratedSchema, nil +} + +func dereferenceList(val reflect.Value, opts DereferenceOptions) ([]any, error) { + hydratedList := make([]any, 0) + for i := 0; i < val.Len(); i++ { + hydratedVal, err := DereferenceSchema(val.Index(i).Interface(), opts) + if err != nil { + return hydratedList, err + } + hydratedList = append(hydratedList, hydratedVal) + } + return hydratedList, nil +} + +func dereferenceMassdriverRef(hydratedSchema map[string]any, schema map[string]any, schemaRefValue string, opts DereferenceOptions) (map[string]any, error) { + referencedSchema, err := GetAsMap(context.Background(), opts.Client, schemaRefValue) + if err != nil { + return hydratedSchema, err + } + + if nestedSchema, exists := referencedSchema["schema"]; exists { + var ok bool + referencedSchema, ok = nestedSchema.(map[string]any) + if !ok { + return hydratedSchema, fmt.Errorf("schema is not a map") + } + } + + hydratedSchema, err = replaceRef(schema, referencedSchema, opts) + if err != nil { + return hydratedSchema, err + } + return hydratedSchema, nil +} + +func dereferenceHTTPRef(hydratedSchema map[string]any, schema map[string]any, schemaRefValue string, opts DereferenceOptions) (map[string]any, error) { + ctx := context.Background() + var referencedSchema map[string]any + + client := http.Client{} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, schemaRefValue, nil) + if err != nil { + return hydratedSchema, err + } + resp, doErr := client.Do(request) + if doErr != nil { + return hydratedSchema, doErr + } + if resp.StatusCode != http.StatusOK { + return hydratedSchema, errors.New("received non-200 response getting ref " + resp.Status + " " + schemaRefValue) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return hydratedSchema, err + } + err = json.Unmarshal(body, &referencedSchema) + if err != nil { + return hydratedSchema, err + } + + hydratedSchema, err = replaceRef(schema, referencedSchema, opts) + return hydratedSchema, err +} + +func dereferenceFilePathRef(hydratedSchema map[string]any, schema map[string]any, schemaRefValue string, opts DereferenceOptions) (map[string]any, error) { + var referencedSchema map[string]any + var schemaRefDir string + schemaRefAbsPath, err := filepath.Abs(filepath.Join(opts.Cwd, schemaRefValue)) + + if err != nil { + return hydratedSchema, err + } + + schemaRefDir = filepath.Dir(schemaRefAbsPath) + referencedSchema, readErr := readJSONFile(schemaRefAbsPath) + + if readErr != nil { + return hydratedSchema, readErr + } + + var replaceErr error + opts.Cwd = schemaRefDir + hydratedSchema, replaceErr = replaceRef(schema, referencedSchema, opts) + if replaceErr != nil { + return hydratedSchema, replaceErr + } + return hydratedSchema, nil +} + +func getValue(anyVal any) reflect.Value { + val := reflect.ValueOf(anyVal) + + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + return val +} + +func readJSONFile(filepath string) (map[string]any, error) { + var result map[string]any + data, err := os.ReadFile(filepath) + + if err != nil { + return result, err + } + err = json.Unmarshal(data, &result) + + return result, err +} + +func replaceRef(base map[string]any, referenced map[string]any, opts DereferenceOptions) (map[string]any, error) { + hydratedSchema := map[string]any{} + delete(base, "$ref") + + for k, v := range referenced { + hydratedValue, err := DereferenceSchema(v, opts) + if err != nil { + return hydratedSchema, err + } + hydratedSchema[k] = hydratedValue + } + return hydratedSchema, nil +} diff --git a/pkg/bundle/dereference_test.go b/pkg/definition/dereference_test.go similarity index 93% rename from pkg/bundle/dereference_test.go rename to pkg/definition/dereference_test.go index b9c05db6..6906a88f 100644 --- a/pkg/bundle/dereference_test.go +++ b/pkg/definition/dereference_test.go @@ -1,4 +1,4 @@ -package bundle_test +package definition_test import ( "encoding/json" @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/massdriver-cloud/mass/pkg/bundle" + "github.com/massdriver-cloud/mass/pkg/definition" "github.com/massdriver-cloud/mass/pkg/gqlmock" "github.com/go-resty/resty/v2" @@ -136,12 +136,12 @@ func TestDereferenceSchema(t *testing.T) { }), } - opts := bundle.DereferenceOptions{ + opts := definition.DereferenceOptions{ Client: &mdClient, Cwd: ".", } - got, gotErr := bundle.DereferenceSchema(test.Input, opts) + got, gotErr := definition.DereferenceSchema(test.Input, opts) if test.ExpectedErrorSuffix == "" && gotErr != nil { t.Errorf("unexpected error: %v", gotErr) @@ -191,11 +191,11 @@ func TestDereferenceSchema(t *testing.T) { input := jsonDecode(fmt.Sprintf(`{"$ref":"%s/recursive"}`, testServer.URL)) - opts := bundle.DereferenceOptions{ + opts := definition.DereferenceOptions{ Client: mdClient, Cwd: ".", } - got, _ := bundle.DereferenceSchema(input, opts) + got, _ := definition.DereferenceSchema(input, opts) expected := map[string]any{ "baz": map[string]string{ "foo": "bar", @@ -208,11 +208,11 @@ func TestDereferenceSchema(t *testing.T) { input = jsonDecode(fmt.Sprintf(`{"$ref":"%s/not-found"}`, testServer.URL)) - opts = bundle.DereferenceOptions{ + opts = definition.DereferenceOptions{ Client: mdClient, Cwd: ".", } - _, gotErr := bundle.DereferenceSchema(input, opts) + _, gotErr := definition.DereferenceSchema(input, opts) expectedErrPrefix := "received non-200 response getting ref 404 Not Found" if !strings.HasPrefix(gotErr.Error(), expectedErrPrefix) { diff --git a/pkg/definition/publish.go b/pkg/definition/publish.go index b860a174..5735f0a0 100644 --- a/pkg/definition/publish.go +++ b/pkg/definition/publish.go @@ -2,9 +2,7 @@ package definition import ( "context" - "encoding/json" "fmt" - "io" "net/url" "github.com/massdriver-cloud/mass/pkg/api" @@ -12,10 +10,10 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" ) -func Publish(ctx context.Context, mdClient *client.Client, in io.Reader) (*api.ArtifactDefinitionWithSchema, error) { - artdefBytes, err := io.ReadAll(in) - if err != nil { - return nil, fmt.Errorf("failed to read artifact definition: %w", err) +func Publish(ctx context.Context, mdClient *client.Client, path string) (*api.ArtifactDefinitionWithSchema, error) { + artDef, readErr := Read(ctx, mdClient, path) + if readErr != nil { + return nil, fmt.Errorf("failed to read artifact definition: %w", readErr) } // validate artifact definition against JSON Schema meta-schema @@ -24,7 +22,7 @@ func Publish(ctx context.Context, mdClient *client.Client, in io.Reader) (*api.A if err != nil { return nil, fmt.Errorf("failed to construct artifact definition schema URL: %w", err) } - err = validateArtifactDefinition(artdefBytes, artdefSchemaURL) + err = validateArtifactDefinition(artDef, artdefSchemaURL) if err != nil { return nil, fmt.Errorf("failed to validate artifact definition schema: %w", err) } @@ -32,24 +30,18 @@ func Publish(ctx context.Context, mdClient *client.Client, in io.Reader) (*api.A if err != nil { return nil, fmt.Errorf("failed to construct meta schema URL: %w", err) } - err = validateArtifactDefinition(artdefBytes, metaSchemaURL) + err = validateArtifactDefinition(artDef, metaSchemaURL) if err != nil { return nil, fmt.Errorf("failed to validate artifact definition against meta schema: %w", err) } - var artdefMap map[string]any - err = json.Unmarshal(artdefBytes, &artdefMap) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal artifact definition: %w", err) - } - - return api.PublishArtifactDefinition(ctx, mdClient, artdefMap) + return api.PublishArtifactDefinition(ctx, mdClient, artDef) } -func validateArtifactDefinition(artdefBytes []byte, schemaURL string) error { +func validateArtifactDefinition(artDef map[string]any, schemaURL string) error { sch, loadErr := jsonschema.LoadSchemaFromURL(schemaURL) if loadErr != nil { return loadErr } - return jsonschema.ValidateBytes(sch, artdefBytes) + return jsonschema.ValidateGo(sch, artDef) } diff --git a/pkg/definition/publish_test.go b/pkg/definition/publish_test.go index 0903d875..5fa90a80 100644 --- a/pkg/definition/publish_test.go +++ b/pkg/definition/publish_test.go @@ -1,7 +1,6 @@ package definition_test import ( - "bytes" "net/http" "net/http/httptest" "os" @@ -17,15 +16,15 @@ import ( func TestPublish(t *testing.T) { type test struct { - name string - definition *bytes.Buffer - wantBody string + name string + path string + wantBody string } tests := []test{ { - name: "simple", - definition: bytes.NewBuffer([]byte(`{"$md":{"access":"public","name":"foo"},"required":["data","specs"],"properties":{"data":{},"specs":{}}}`)), - wantBody: `{"$md":{"access":"public","name":"foo"},"required":["data","specs"],"properties":{"data":{},"specs":{}}}`, + name: "simple", + path: "testdata/simple-artifact.json", + wantBody: `{"$schema":"http://json-schema.org/draft-07/schema","type":"object","title":"Test Artifact","properties":{"data":{"type":"object"}},"specs":{"type":"object"}}}`, }, } @@ -67,7 +66,7 @@ func TestPublish(t *testing.T) { }, } - _, err = definition.Publish(t.Context(), &mdClient, tc.definition) + _, err = definition.Publish(t.Context(), &mdClient, tc.path) if err != nil { t.Fatalf("%v, unexpected error", err) } diff --git a/pkg/definition/read.go b/pkg/definition/read.go new file mode 100644 index 00000000..30b9bef8 --- /dev/null +++ b/pkg/definition/read.go @@ -0,0 +1,52 @@ +package definition + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" + "gopkg.in/yaml.v3" +) + +func Read(ctx context.Context, mdClient *client.Client, path string) (map[string]any, error) { + artdefBytes, readErr := os.ReadFile(path) + if readErr != nil { + return nil, fmt.Errorf("failed to read artifact definition: %w", readErr) + } + + var artdefMap map[string]any + switch filepath.Ext(path) { + case ".json": + jsonErr := json.Unmarshal(artdefBytes, &artdefMap) + if jsonErr != nil { + return nil, fmt.Errorf("failed to unmarshal artifact definition JSON: %w", jsonErr) + } + case ".yaml", ".yml": + yamlErr := yaml.Unmarshal(artdefBytes, &artdefMap) + if yamlErr != nil { + return nil, fmt.Errorf("failed to unmarshal artifact definition YAML: %w", yamlErr) + } + default: + return nil, fmt.Errorf("unsupported artifact definition file extension: %s", filepath.Ext(path)) + } + + // Dereferencing here. We may want to break this out in the future, but for now Reading and Dereferencing should be coupled. + opts := DereferenceOptions{ + Client: mdClient, + Cwd: filepath.Dir(path), + } + dereferencedArtifactAny, derefErr := DereferenceSchema(artdefMap, opts) + if derefErr != nil { + return nil, fmt.Errorf("failed to dereference artifact definition: %w", derefErr) + } + + dereferencedArtifact, ok := dereferencedArtifactAny.(map[string]any) + if !ok { + return nil, fmt.Errorf("dereferenced artifact definition is not a map") + } + + return dereferencedArtifact, nil +} diff --git a/pkg/definition/read_test.go b/pkg/definition/read_test.go new file mode 100644 index 00000000..965a1f13 --- /dev/null +++ b/pkg/definition/read_test.go @@ -0,0 +1,62 @@ +package definition_test + +import ( + "context" + "path/filepath" + "reflect" + "testing" + + "github.com/massdriver-cloud/mass/pkg/definition" + "github.com/massdriver-cloud/mass/pkg/gqlmock" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/client" +) + +func TestRead(t *testing.T) { + want := map[string]any{ + "$schema": "http://json-schema.org/draft-07/schema", + "$md": map[string]any{ + "name": "foo", + }, + "type": "object", + "title": "Test Artifact", + "properties": map[string]any{ + "data": map[string]any{ + "type": "object", + }, + "specs": map[string]any{ + "type": "object", + }, + }, + } + type test struct { + name string + file string + } + tests := []test{ + { + name: "json", + file: filepath.Join("testdata", "simple-artifact.json"), + }, + { + name: "yaml", + file: filepath.Join("testdata", "simple-artifact.yaml"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mdClient := client.Client{ + GQL: gqlmock.NewClientWithSingleJSONResponse(map[string]any{"data": map[string]any{}}), + } + + got, err := definition.Read(context.Background(), &mdClient, tc.file) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} diff --git a/pkg/bundle/testdata/dereference/aws-example.json b/pkg/definition/testdata/dereference/aws-example.json similarity index 100% rename from pkg/bundle/testdata/dereference/aws-example.json rename to pkg/definition/testdata/dereference/aws-example.json diff --git a/pkg/bundle/testdata/dereference/conflicting-keys.json b/pkg/definition/testdata/dereference/conflicting-keys.json similarity index 100% rename from pkg/bundle/testdata/dereference/conflicting-keys.json rename to pkg/definition/testdata/dereference/conflicting-keys.json diff --git a/pkg/bundle/testdata/dereference/ref-aws-example.json b/pkg/definition/testdata/dereference/ref-aws-example.json similarity index 100% rename from pkg/bundle/testdata/dereference/ref-aws-example.json rename to pkg/definition/testdata/dereference/ref-aws-example.json diff --git a/pkg/bundle/testdata/dereference/ref-lower-dir-aws-example.json b/pkg/definition/testdata/dereference/ref-lower-dir-aws-example.json similarity index 100% rename from pkg/bundle/testdata/dereference/ref-lower-dir-aws-example.json rename to pkg/definition/testdata/dereference/ref-lower-dir-aws-example.json diff --git a/pkg/definition/testdata/simple-artifact.json b/pkg/definition/testdata/simple-artifact.json new file mode 100644 index 00000000..8fda41e8 --- /dev/null +++ b/pkg/definition/testdata/simple-artifact.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$md": { + "name": "foo" + }, + "type": "object", + "title": "Test Artifact", + "properties": { + "data": { + "type": "object" + }, + "specs": { + "type": "object" + } + } +} diff --git a/pkg/definition/testdata/simple-artifact.yaml b/pkg/definition/testdata/simple-artifact.yaml new file mode 100644 index 00000000..3743e3ba --- /dev/null +++ b/pkg/definition/testdata/simple-artifact.yaml @@ -0,0 +1,10 @@ +$schema: http://json-schema.org/draft-07/schema +$md: + name: foo +type: object +title: Test Artifact +properties: + data: + type: object + specs: + type: object