From 8ca31d05c078156ea7031e6496d1e676fd1a9ca8 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 11 Apr 2023 11:04:34 +0200 Subject: [PATCH] feat(app,manifest): validation + extendable metadatas --- cmd/cli/command/app/common.go | 11 +++ cmd/cli/command/app/package.go | 4 + cmd/cli/command/app/run.go | 4 + doc/README.md | 1 + doc/apps/manifest.md | 36 ++++++++ doc/apps/my-first-app.md | 18 +--- pkg/app/app.go | 39 --------- pkg/app/manifest.go | 85 +++++++++++++++++++ pkg/app/metadata/minimum_role.go | 28 ++++++ pkg/app/metadata/named_path.go | 56 ++++++++++++ .../manifests/invalid-minimum-role.yml | 7 ++ .../testdata/manifests/invalid-paths.yml | 10 +++ pkg/app/metadata/testdata/manifests/valid.yml | 10 +++ pkg/app/metadata/validator_test.go | 74 ++++++++++++++++ 14 files changed, 327 insertions(+), 56 deletions(-) create mode 100644 cmd/cli/command/app/common.go create mode 100644 doc/apps/manifest.md delete mode 100644 pkg/app/app.go create mode 100644 pkg/app/manifest.go create mode 100644 pkg/app/metadata/minimum_role.go create mode 100644 pkg/app/metadata/named_path.go create mode 100644 pkg/app/metadata/testdata/manifests/invalid-minimum-role.yml create mode 100644 pkg/app/metadata/testdata/manifests/invalid-paths.yml create mode 100644 pkg/app/metadata/testdata/manifests/valid.yml create mode 100644 pkg/app/metadata/validator_test.go diff --git a/cmd/cli/command/app/common.go b/cmd/cli/command/app/common.go new file mode 100644 index 0000000..7385c83 --- /dev/null +++ b/cmd/cli/command/app/common.go @@ -0,0 +1,11 @@ +package app + +import ( + "forge.cadoles.com/arcad/edge/pkg/app" + "forge.cadoles.com/arcad/edge/pkg/app/metadata" +) + +var manifestMetadataValidators = []app.MetadataValidator{ + metadata.WithMinimumRoleValidator("visitor", "user", "superuser", "admin", "superadmin"), + metadata.WithNamedPathsValidator(metadata.NamedPathAdmin, metadata.NamedPathIcon), +} diff --git a/cmd/cli/command/app/package.go b/cmd/cli/command/app/package.go index 930c025..1c2a5c6 100644 --- a/cmd/cli/command/app/package.go +++ b/cmd/cli/command/app/package.go @@ -52,6 +52,10 @@ func PackageCommand() *cli.Command { return errors.Wrap(err, "could not load app manifest") } + if valid, err := manifest.Validate(manifestMetadataValidators...); !valid { + return errors.Wrap(err, "invalid app manifest") + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { return errors.Wrapf(err, "could not create directory ''%s'", outputDir) } diff --git a/cmd/cli/command/app/run.go b/cmd/cli/command/app/run.go index 0129ba9..9fe5169 100644 --- a/cmd/cli/command/app/run.go +++ b/cmd/cli/command/app/run.go @@ -110,6 +110,10 @@ func RunCommand() *cli.Command { return errors.Wrap(err, "could not load manifest from app bundle") } + if valid, err := manifest.Validate(manifestMetadataValidators...); !valid { + return errors.Wrap(err, "invalid app manifest") + } + storageFile := injectAppID(ctx.String("storage-file"), manifest.ID) if err := ensureDir(storageFile); err != nil { diff --git a/doc/README.md b/doc/README.md index 86425e9..2a8c7fa 100644 --- a/doc/README.md +++ b/doc/README.md @@ -6,6 +6,7 @@ Une **Edge App** est une application capable de s'exécuter dans un environnemen ### Référence +- [Fichier `manifest.yml`](./apps/manifest.md) - [API Client](./apps/client-api/README.md) - [API Serveur](./apps/server-api/README.md) diff --git a/doc/apps/manifest.md b/doc/apps/manifest.md new file mode 100644 index 0000000..40ec02c --- /dev/null +++ b/doc/apps/manifest.md @@ -0,0 +1,36 @@ +# Le fichier `manifest.yml` + +Le fichier `manifest.yml` à la racine du bundle de votre application contient des informations décrivant celles ci. Vous trouverez ci dessous un exemple commenté. + +```yaml +# REQUIS - L'identifiant de votre application. Il doit être globalement unique. +# Un identifiant du type nom de domaine inversé est en général conseillé (ex: tld.mycompany.myapp) +id: tld.mycompany.myapp + +# REQUIS - Le numéro de version de votre application +# Celui ci devrait respecter le format "semver 2" (voir https://semver.org/) +version: 0.0.0 + +# REQUIS - Le titre de votre application. +title: My App + +# OPTIONNEL - Les mots-clés associés à votre applications. +tags: ["chat"] + +# OPTIONNEL - La description de votre application. +# Vous pouvez utiliser la syntaxe Markdown pour la mettre en forme. +description: |> + A simple demo application + +# OPTIONNEL - Métadonnées associées à l'application +metadata: + # OPTIONNEL - Liste des chemins permettant d'accéder à certains URLs identifiées (page d'administration, icône si existante, etc) + paths: + # Si défini, chemin vers la page d'administration de l'application + admin: /admin + # Si défini, chemin vers l'icône associée à l'application + icon: /my-app-icon.png + + # OPTIONNEL - Role minimum requis pour pouvoir accéder à l'application + minimumRole: visitor +``` \ No newline at end of file diff --git a/doc/apps/my-first-app.md b/doc/apps/my-first-app.md index ca38371..8777247 100644 --- a/doc/apps/my-first-app.md +++ b/doc/apps/my-first-app.md @@ -22,23 +22,7 @@ my-app Ce fichier est le manifeste de votre application. Il permet au serveur d'identifier celle ci et de récupérer des informations la concernant. -```yaml ---- -# L'identifiant de votre application. Il doit être globalement unique. -# Un identifiant du type nom de domaine inversé est en général conseillé (ex: tld.mycompany.myapp) -id: tld.mycompany.myapp - -# Le titre de votre application. -title: My App - -# Les mots-clés associés à votre applications. -tags: ["chat"] - -# La description de votre application. -# Vous pouvez utiliser la syntaxe Markdown pour la mettre en forme. -description: |> - A simple demo application -``` +[Voir le fichier `manifest.yml` d'exemple](./manifest.md) ## 4. Créer la page d'accueil diff --git a/pkg/app/app.go b/pkg/app/app.go deleted file mode 100644 index 5d5d81c..0000000 --- a/pkg/app/app.go +++ /dev/null @@ -1,39 +0,0 @@ -package app - -import ( - "forge.cadoles.com/arcad/edge/pkg/bundle" - "github.com/pkg/errors" - "gopkg.in/yaml.v2" -) - -type ID string - -type Manifest struct { - ID ID `yaml:"id" json:"id"` - Version string `yaml:"version" json:"version"` - Title string `yaml:"title" json:"title"` - Description string `yaml:"description" json:"description"` - Tags []string `yaml:"tags" json:"tags"` -} - -func LoadManifest(b bundle.Bundle) (*Manifest, error) { - reader, _, err := b.File("manifest.yml") - if err != nil { - return nil, errors.Wrap(err, "could not read manifest.yml") - } - - defer func() { - if err := reader.Close(); err != nil { - panic(errors.WithStack(err)) - } - }() - - manifest := &Manifest{} - - decoder := yaml.NewDecoder(reader) - if err := decoder.Decode(manifest); err != nil { - return nil, errors.Wrap(err, "could not decode manifest.yml") - } - - return manifest, nil -} diff --git a/pkg/app/manifest.go b/pkg/app/manifest.go new file mode 100644 index 0000000..b610df8 --- /dev/null +++ b/pkg/app/manifest.go @@ -0,0 +1,85 @@ +package app + +import ( + "strings" + + "forge.cadoles.com/arcad/edge/pkg/bundle" + "github.com/pkg/errors" + "golang.org/x/mod/semver" + "gopkg.in/yaml.v2" +) + +type ID string + +type Manifest struct { + ID ID `yaml:"id" json:"id"` + Version string `yaml:"version" json:"version"` + Title string `yaml:"title" json:"title"` + Description string `yaml:"description" json:"description"` + Tags []string `yaml:"tags" json:"tags"` + Metadata map[string]any `yaml:"metadata" json:"metadata"` +} + +type MetadataValidator func(map[string]any) (bool, error) + +func (m *Manifest) Validate(validators ...MetadataValidator) (bool, error) { + if m.ID == "" { + return false, errors.New("'id' property should not be empty") + } + + if m.Version == "" { + return false, errors.New("'version' property should not be empty") + } + + version := m.Version + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + + if !semver.IsValid(version) { + return false, errors.Errorf("version '%s' does not respect semver format", m.Version) + } + + if m.Title == "" { + return false, errors.New("'title' property should not be empty") + } + + if m.Tags != nil { + for _, t := range m.Tags { + if strings.ContainsAny(t, " \t\n\r") { + return false, errors.Errorf("tag '%s' should not contain any space or new line", t) + } + } + } + + for _, v := range validators { + valid, err := v(m.Metadata) + if !valid || err != nil { + return valid, errors.WithStack(err) + } + } + + return true, nil +} + +func LoadManifest(b bundle.Bundle) (*Manifest, error) { + reader, _, err := b.File("manifest.yml") + if err != nil { + return nil, errors.Wrap(err, "could not read manifest.yml") + } + + defer func() { + if err := reader.Close(); err != nil { + panic(errors.WithStack(err)) + } + }() + + manifest := &Manifest{} + + decoder := yaml.NewDecoder(reader) + if err := decoder.Decode(manifest); err != nil { + return nil, errors.Wrap(err, "could not decode manifest.yml") + } + + return manifest, nil +} diff --git a/pkg/app/metadata/minimum_role.go b/pkg/app/metadata/minimum_role.go new file mode 100644 index 0000000..c4b6d11 --- /dev/null +++ b/pkg/app/metadata/minimum_role.go @@ -0,0 +1,28 @@ +package metadata + +import ( + "forge.cadoles.com/arcad/edge/pkg/app" + "github.com/pkg/errors" +) + +func WithMinimumRoleValidator(roles ...string) app.MetadataValidator { + return func(metadata map[string]any) (bool, error) { + rawMinimumRole, exists := metadata["minimumRole"] + if !exists { + return true, nil + } + + minimumRole, ok := rawMinimumRole.(string) + if !ok { + return false, errors.Errorf("metadata['minimumRole']: unexpected value type '%T'", rawMinimumRole) + } + + for _, r := range roles { + if minimumRole == r { + return true, nil + } + } + + return false, errors.Errorf("metadata['minimumRole']: unexpected role '%s'", minimumRole) + } +} diff --git a/pkg/app/metadata/named_path.go b/pkg/app/metadata/named_path.go new file mode 100644 index 0000000..a2ac554 --- /dev/null +++ b/pkg/app/metadata/named_path.go @@ -0,0 +1,56 @@ +package metadata + +import ( + "strings" + + "forge.cadoles.com/arcad/edge/pkg/app" + "github.com/pkg/errors" +) + +type NamedPath string + +const ( + NamedPathAdmin NamedPath = "admin" + NamedPathIcon NamedPath = "icon" +) + +func WithNamedPathsValidator(names ...NamedPath) app.MetadataValidator { + set := map[NamedPath]struct{}{} + for _, n := range names { + set[n] = struct{}{} + } + + return func(metadata map[string]any) (bool, error) { + rawPaths, exists := metadata["paths"] + if !exists { + return true, nil + } + + paths, ok := rawPaths.(map[any]any) + if !ok { + return false, errors.Errorf("metadata['paths']: unexpected named path value type '%T'", rawPaths) + } + + for n, p := range paths { + name, ok := n.(string) + if !ok { + return false, errors.Errorf("metadata['paths']: unexpected named path type '%T'", n) + } + + if _, exists := set[NamedPath(name)]; !exists { + return false, errors.Errorf("metadata['paths']: unexpected named path '%s'", name) + } + + path, ok := p.(string) + if !ok { + return false, errors.Errorf("metadata['paths']['%s']: unexpected named path value type '%T'", name, path) + } + + if !strings.HasPrefix(path, "/") { + return false, errors.Errorf("metadata['paths']['%s']: named path value should start with a '/'", name) + } + } + + return true, nil + } +} diff --git a/pkg/app/metadata/testdata/manifests/invalid-minimum-role.yml b/pkg/app/metadata/testdata/manifests/invalid-minimum-role.yml new file mode 100644 index 0000000..e0d2d13 --- /dev/null +++ b/pkg/app/metadata/testdata/manifests/invalid-minimum-role.yml @@ -0,0 +1,7 @@ +id: foo.arcad.app +version: v0.0.0 +title: Foo +description: A test app +tags: ["test"] +metadata: + minimumRole: foo \ No newline at end of file diff --git a/pkg/app/metadata/testdata/manifests/invalid-paths.yml b/pkg/app/metadata/testdata/manifests/invalid-paths.yml new file mode 100644 index 0000000..d72c466 --- /dev/null +++ b/pkg/app/metadata/testdata/manifests/invalid-paths.yml @@ -0,0 +1,10 @@ +id: foo.arcad.app +version: v0.0.0 +title: Foo +description: A test app +tags: ["test"] +metadata: + paths: + invalid: /admin + icon: /my-app-icon.png + minimumRole: visitor \ No newline at end of file diff --git a/pkg/app/metadata/testdata/manifests/valid.yml b/pkg/app/metadata/testdata/manifests/valid.yml new file mode 100644 index 0000000..655abcb --- /dev/null +++ b/pkg/app/metadata/testdata/manifests/valid.yml @@ -0,0 +1,10 @@ +id: foo.arcad.app +version: v0.0.0 +title: Foo +description: A test app +tags: ["test"] +metadata: + paths: + admin: /admin + icon: /my-app-icon.png + minimumRole: visitor diff --git a/pkg/app/metadata/validator_test.go b/pkg/app/metadata/validator_test.go new file mode 100644 index 0000000..92d12ac --- /dev/null +++ b/pkg/app/metadata/validator_test.go @@ -0,0 +1,74 @@ +package metadata + +import ( + "io/ioutil" + "path/filepath" + "testing" + + "forge.cadoles.com/arcad/edge/pkg/app" + "github.com/pkg/errors" + "gopkg.in/yaml.v2" +) + +type validatorTestCase struct { + File string + ExpectValid bool + ExpectError bool +} + +var validatorTestCases = []validatorTestCase{ + { + File: "valid.yml", + ExpectValid: true, + }, + { + File: "invalid-paths.yml", + ExpectValid: false, + ExpectError: true, + }, + { + File: "invalid-minimum-role.yml", + ExpectValid: false, + ExpectError: true, + }, +} + +var validators = []app.MetadataValidator{ + WithMinimumRoleValidator("visitor", "user", "superuser", "admin", "superadmin"), + WithNamedPathsValidator(NamedPathAdmin, NamedPathIcon), +} + +func TestManifestValidate(t *testing.T) { + for _, tc := range validatorTestCases { + func(tc *validatorTestCase) { + t.Run(tc.File, func(t *testing.T) { + data, err := ioutil.ReadFile(filepath.Join("testdata/manifests", tc.File)) + if err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + var manifest app.Manifest + + if err := yaml.Unmarshal(data, &manifest); err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + valid, err := manifest.Validate(validators...) + + t.Logf("[RESULT] valid:%v, err:%v", valid, err) + + if e, g := tc.ExpectValid, valid; e != g { + t.Errorf("valid: expected '%v', got '%v'", e, g) + } + + if tc.ExpectError && err == nil { + t.Error("err should not be nil") + } + + if !tc.ExpectError && err != nil { + t.Errorf("err: expected nil, got '%+v'", err) + } + }) + }(&tc) + } +}