Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 5 additions & 24 deletions cmd/topo/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,11 @@ var projectsCmd = &cobra.Command{

var projects []catalog.Project
var err error
source := getSource(cmd)
switch source {
case builtinProjects:
projects, err = catalog.ListBuiltinProjects()
default:
projects, err = catalog.ListProjectsFromURL(ctx, source)
source, err := cmd.Flags().GetString(sourceFlag)
if err != nil {
panic(fmt.Sprintf("internal error: %s flag not registered: %v", sourceFlag, err))
}
projects, err = catalog.ListProjectsFromURL(ctx, source)
if err != nil {
return err
}
Expand All @@ -56,23 +54,6 @@ var projectsCmd = &cobra.Command{
func init() {
addTargetFlag(projectsCmd)
addTimeoutFlag(projectsCmd, defaultTimeout)
if experimentalFeaturesEnabled() {
projectsCmd.Flags().StringP(sourceFlag, "s", "", "where to source projects' data from")
}
projectsCmd.Flags().StringP(sourceFlag, "s", catalog.DefaultCatalogURL, "where to source projects' data from")
rootCmd.AddCommand(projectsCmd)
}

const builtinProjects = "builtin"

func getSource(cmd *cobra.Command) string {
if experimentalFeaturesEnabled() {
flagValue, err := cmd.Flags().GetString(sourceFlag)
if err != nil {
panic(fmt.Sprintf("internal error: %s flag not registered: %v", sourceFlag, err))
}
if flagValue != "" {
return flagValue
}
}
return builtinProjects
}
16 changes: 16 additions & 0 deletions docs/development/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,19 @@ docker compose up
```

The documentation preview is available at `http://localhost:3000` and automatically reloads when files change.

## Updating the catalog schema

Topo uses the catalog version declared in `internal/catalog/catalog_schema_generated.go`. The same version selects both the catalog and its schema from Artifactory.

Generating the Go types requires Node.js 20 or newer, `npx`, and access to Artifactory. Pass the catalog version to the generator:

```sh
go run ./scripts/generate_catalog_types v1
```

The generator downloads that version's `catalog.schema.json`, generates the catalog Go types with the pinned Quicktype version, and rewrites `internal/catalog/catalog_schema_generated.go`. To move to another published major version, replace `v1` with that version.

Commit the newly generated catalog schema types file and raise a PR to update the main branch.

Available catalog versions and schemas are published in the [Topo Project Catalog Artifactory repository](https://artifacts.tools.arm.com/devx-topo-project-catalog/).
107 changes: 12 additions & 95 deletions internal/catalog/catalog.go
Original file line number Diff line number Diff line change
@@ -1,85 +1,35 @@
package catalog

import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"

"github.com/santhosh-tekuri/jsonschema/v6"
"github.com/arm/topo/internal/fetch"
)

//go:embed data/catalog.json
var catalogJSON []byte
type Project = ProjectElement

//go:embed data/catalog.schema.json
var catalogSchemaJSON []byte

type catalogDocument struct {
Schema string `json:"$schema,omitempty"`
Projects []Project `json:"projects"`
}

type Project struct {
Name string `json:"name"`
Description string `json:"description"`
Features []string `json:"features"`
URL string `json:"url"`
Ref string `json:"ref"`
}

func ListBuiltinProjects() ([]Project, error) {
return parseProjects(catalogJSON)
}
const (
defaultURL = "https://artifacts.tools.arm.com/devx-topo-project-catalog/" + CatalogMajorVersion + "/catalog/"
DefaultCatalogURL = defaultURL + "catalog.json"
)

func ListProjectsFromURL(ctx context.Context, url string) ([]Project, error) {
data, err := fetchProjectsJSON(ctx, url)
if err != nil {
return nil, fmt.Errorf("failed to fetch projects: %w", err)
}
return parseProjects(data)
return parseProjects(ctx, data)
}

func parseProjects(b []byte) ([]Project, error) {
if err := validateAgainstSchema(b); err != nil {
return nil, fmt.Errorf("failed schema validation: %w", err)
}

var catalog catalogDocument
if err := json.Unmarshal(b, &catalog); err != nil {
return nil, fmt.Errorf("failed to unmarshal projects: %w", err)
}

return catalog.Projects, nil
}

func validateAgainstSchema(b []byte) error {
const projectsSchemaURL = "https://raw.githubusercontent.com/arm/topo/main/internal/catalog/data/catalog.schema.json"

compiler := jsonschema.NewCompiler()
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(catalogSchemaJSON))
func parseProjects(ctx context.Context, b []byte) ([]Project, error) {
catalog, err := UnmarshalCatalogDocument(b)
if err != nil {
return fmt.Errorf("failed to unmarshal schema: %w", err)
return nil, fmt.Errorf("failed to unmarshal catalog: %w", err)
}
if err := compiler.AddResource(projectsSchemaURL, schemaDoc); err != nil {
return fmt.Errorf("failed to add schema resource: %w", err)
}
schema, err := compiler.Compile(projectsSchemaURL)
if err != nil {
return fmt.Errorf("failed to compile schema: %w", err)
}

jsonDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b))
if err != nil {
return fmt.Errorf("failed to unmarshal projects: %w", err)
}
return schema.Validate(jsonDoc)
return catalog.Projects, nil
}

func fetchProjectsJSON(ctx context.Context, url string) ([]byte, error) {
Expand All @@ -92,42 +42,9 @@ func fetchProjectsJSON(ctx context.Context, url string) ([]byte, error) {
return data, nil
}

data, err := httpGet(ctx, url)
data, err := fetch.Get(ctx, url)
if err != nil {
return nil, fmt.Errorf("failed to fetch project: %w", err)
}
return data, nil
}

func httpGet(ctx context.Context, rawURL string) ([]byte, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}

if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, fmt.Errorf("unsupported URL scheme: %s", parsedURL.Scheme)
}

req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
parsedURL.String(),
nil,
)
if err != nil {
return nil, err
}

resp, err := http.DefaultClient.Do(req) // #nosec G704 -- URL is explicitly provided by the CLI user and scheme-validated above.
if err != nil {
return nil, err
}
defer resp.Body.Close() // nolint:errcheck

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("request failed: %s", resp.Status)
}

return io.ReadAll(resp.Body)
}
68 changes: 68 additions & 0 deletions internal/catalog/catalog_schema_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 1 addition & 12 deletions internal/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,6 @@ func TestListProjectsFromURL(t *testing.T) {
assert.Equal(t, projects, got)
})

t.Run("errors when payload doesn't validate against schema", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "file.json")
projects := []catalog.Project{{Name: "aloha"}}
testutil.RequireWriteFile(t, path, string(asJSON(projects)))

url := fmt.Sprintf("file://%s", path)
_, err := catalog.ListProjectsFromURL(context.Background(), url)

require.Error(t, err)
})

t.Run("errors when request fails", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
Expand All @@ -76,7 +65,7 @@ func TestListProjectsFromURL(t *testing.T) {
_, err := catalog.ListProjectsFromURL(context.Background(), url)

require.Error(t, err)
assert.ErrorContains(t, err, "failed to unmarshal projects")
assert.ErrorContains(t, err, "failed to unmarshal catalog")
})
}

Expand Down
42 changes: 42 additions & 0 deletions internal/fetch/fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package fetch

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
)

func Get(ctx context.Context, rawURL string) ([]byte, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parsing URL failed: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, fmt.Errorf("unsupported URL scheme: %s", parsedURL.Scheme)
}

request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request failed: %w", err)
}

// #nosec G704 -- callers explicitly provide the URL and its scheme is validated above.
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("sending request failed: %w", err)
}

if response.StatusCode != http.StatusOK {
statusErr := fmt.Errorf("request failed: HTTP %d (%s)", response.StatusCode, response.Status)
return nil, errors.Join(statusErr, response.Body.Close())
}

data, readErr := io.ReadAll(response.Body)
if err := errors.Join(readErr, response.Body.Close()); err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
return data, nil
}
Loading
Loading