diff --git a/tools/resourcedocsgen/cmd/docs/registry.go b/tools/resourcedocsgen/cmd/docs/registry.go index 23a0a4fef5..af4623ce24 100644 --- a/tools/resourcedocsgen/cmd/docs/registry.go +++ b/tools/resourcedocsgen/cmd/docs/registry.go @@ -15,12 +15,12 @@ package docs import ( + "context" "encoding/json" "fmt" "io" "net/http" "net/url" - "os" "path/filepath" "runtime" "strings" @@ -36,6 +36,7 @@ import ( pschema "github.com/pulumi/pulumi/pkg/v3/codegen/schema" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/registry/tools/resourcedocsgen/pkg" + "github.com/pulumi/registry/tools/resourcedocsgen/pkg/registry/svc" concpool "github.com/sourcegraph/conc/pool" ) @@ -132,46 +133,30 @@ func getSchemaFileURL(metadata pkg.PackageMeta) (string, error) { return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repoSlug, metadata.Version, schemaFilePath), nil } -func getRegistryPackagesPath(repoPath string) string { - return filepath.Join(repoPath, "themes", "default", "data", "registry", "packages") -} - -func genResourceDocsForAllRegistryPackages(registryRepoPath, baseDocsOutDir, basePackageTreeJSONOutDir string) error { - registryPackagesPath := getRegistryPackagesPath(registryRepoPath) - metadataFiles, err := os.ReadDir(registryPackagesPath) +func genResourceDocsForAllRegistryPackages( + ctx context.Context, + provider svc.PackageMetadataProvider, + baseDocsOutDir, basePackageTreeJSONOutDir string, +) error { + metadataList, err := provider.ListPackageMetadata(ctx) if err != nil { - return errors.Wrap(err, "reading the registry packages dir") + return errors.Wrap(err, "listing package metadata") } pool := concpool.New().WithErrors().WithMaxGoroutines(runtime.NumCPU()) - for _, f := range metadataFiles { - f := f + for _, metadata := range metadataList { pool.Go(func() error { - glog.Infof("=== starting %s ===\n", f.Name()) - glog.Infoln("Processing metadata file") - metadataFilePath := filepath.Join(registryPackagesPath, f.Name()) - - b, err := os.ReadFile(metadataFilePath) - if err != nil { - return errors.Wrapf(err, "reading the metadata file %s", metadataFilePath) - } - - var metadata pkg.PackageMeta - if err := yaml.Unmarshal(b, &metadata); err != nil { - return errors.Wrapf(err, "unmarshalling the metadata file %s", metadataFilePath) - } - + glog.Infof("=== starting %s ===\n", metadata.Name) docsOutDir := filepath.Join(baseDocsOutDir, metadata.Name, "api-docs") err = genResourceDocsForPackageFromRegistryMetadata(metadata, docsOutDir, basePackageTreeJSONOutDir) if err != nil { - return errors.Wrapf(err, "generating resource docs using metadata file info %s", f.Name()) + return errors.Wrapf(err, "generating resource docs using metadata file info %s", metadata.Name) } - glog.Infof("=== completed %s ===", f.Name()) + glog.Infof("=== completed %s ===", metadata.Name) return nil }) } - return pool.Wait() } @@ -179,6 +164,8 @@ func resourceDocsFromRegistryCmd() *cobra.Command { var baseDocsOutDir string var basePackageTreeJSONOutDir string var registryDir string + var useAPI bool + var apiURL string cmd := &cobra.Command{ Use: "registry [pkgName]", @@ -186,35 +173,29 @@ func resourceDocsFromRegistryCmd() *cobra.Command { Long: "Generate resource docs for all packages in the registry or specific packages. " + "Pass a package name in the registry as an optional arg to generate docs only for that package.", RunE: func(cmd *cobra.Command, args []string) error { - registryDir, err := filepath.Abs(registryDir) - if err != nil { - return errors.Wrap(err, "finding the cwd") + ctx := cmd.Context() + var provider svc.PackageMetadataProvider + if useAPI { + provider = svc.NewAPIProvider(apiURL) + } else { + provider = svc.NewFileSystemProvider(registryDir) } if len(args) > 0 { glog.Infoln("Generating docs for a single package:", args[0]) - registryPackagesPath := getRegistryPackagesPath(registryDir) - pkgName := args[0] - metadataFilePath := filepath.Join(registryPackagesPath, pkgName+".yaml") - b, err := os.ReadFile(metadataFilePath) + metadata, err := provider.GetPackageMetadata(ctx, args[0]) if err != nil { - return errors.Wrapf(err, "reading the metadata file %s", metadataFilePath) - } - - var metadata pkg.PackageMeta - if err := yaml.Unmarshal(b, &metadata); err != nil { - return errors.Wrapf(err, "unmarshalling the metadata file %s", metadataFilePath) + return errors.Wrapf(err, "getting metadata for package %q", args[0]) } docsOutDir := filepath.Join(baseDocsOutDir, metadata.Name, "api-docs") - err = genResourceDocsForPackageFromRegistryMetadata(metadata, docsOutDir, basePackageTreeJSONOutDir) if err != nil { - return errors.Wrapf(err, "generating docs for package %q from registry metadata", pkgName) + return errors.Wrapf(err, "generating docs for package %q from registry metadata", args[0]) } } else { glog.Infoln("Generating docs for all packages in the registry...") - err := genResourceDocsForAllRegistryPackages(registryDir, baseDocsOutDir, basePackageTreeJSONOutDir) + err := genResourceDocsForAllRegistryPackages(ctx, provider, baseDocsOutDir, basePackageTreeJSONOutDir) if err != nil { return errors.Wrap(err, "generating docs for all packages from registry metadata") } @@ -234,6 +215,10 @@ func resourceDocsFromRegistryCmd() *cobra.Command { cmd.Flags().StringVar(®istryDir, "registryDir", ".", "The root of the pulumi/registry directory") + cmd.Flags().BoolVar(&useAPI, "use-api", false, "Use the Pulumi Registry API instead of local files") + cmd.Flags().StringVar(&apiURL, "api-url", + "https://api.pulumi.com/api/preview/registry", + "URL of the Pulumi Registry API") return cmd } diff --git a/tools/resourcedocsgen/pkg/registry/svc/api.go b/tools/resourcedocsgen/pkg/registry/svc/api.go new file mode 100644 index 0000000000..380fa739d9 --- /dev/null +++ b/tools/resourcedocsgen/pkg/registry/svc/api.go @@ -0,0 +1,173 @@ +// Copyright 2025, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package svc + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "time" + + "github.com/pkg/errors" + + "github.com/pulumi/registry/tools/resourcedocsgen/pkg" +) + +// NewAPIProvider creates a new PackageMetadataProvider that reads from the Pulumi API +func NewAPIProvider(apiURL string) PackageMetadataProvider { + return ®istryAPIProvider{ + apiURL: apiURL, + client: http.DefaultClient, + } +} + +// registryAPIProvider implements PackageMetadataProvider using the Pulumi API +// to retrieve package metadata. +type registryAPIProvider struct { + apiURL string + client *http.Client +} + +// apiPackageMetadata represents the API response structure for package metadata +// from the Pulumi Registry API. +type apiPackageMetadata struct { + Name string `json:"name"` + Publisher string `json:"publisher"` + Source string `json:"source"` + Version string `json:"version"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + LogoURL string `json:"logoUrl,omitempty"` + RepoURL string `json:"repoUrl,omitempty"` + Category string `json:"category,omitempty"` + IsFeatured bool `json:"isFeatured"` + PackageTypes []string `json:"packageTypes,omitempty"` + PackageStatus string `json:"packageStatus"` + SchemaURL string `json:"schemaURL"` + CreatedAt time.Time `json:"createdAt"` +} + +// packageListResponse represents the API response structure for package lists +type packageListResponse struct { + Packages []apiPackageMetadata `json:"packages"` +} + +func convertAPIPackageToPackageMeta(apiPkg apiPackageMetadata) pkg.PackageMeta { + return pkg.PackageMeta{ + Name: apiPkg.Name, + Publisher: apiPkg.Publisher, + Description: apiPkg.Description, + LogoURL: apiPkg.LogoURL, + RepoURL: apiPkg.RepoURL, + Category: pkg.PackageCategory(apiPkg.Category), + Featured: apiPkg.IsFeatured, + Native: slices.Contains(apiPkg.PackageTypes, "native"), + Component: slices.Contains(apiPkg.PackageTypes, "component"), + PackageStatus: pkg.PackageStatus(apiPkg.PackageStatus), + SchemaFileURL: apiPkg.SchemaURL, + Version: apiPkg.Version, + Title: apiPkg.Title, + UpdatedOn: apiPkg.CreatedAt.Unix(), + } +} + +// GetPackageMetadata implements PackageMetadataProvider for registryAPIProvider +func (p *registryAPIProvider) GetPackageMetadata(ctx context.Context, pkgName string) (pkg.PackageMeta, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%s/packages?name=%s", p.apiURL, pkgName), nil) + if err != nil { + return pkg.PackageMeta{}, errors.Wrapf(err, "creating request for package %s", pkgName) + } + + resp, err := p.client.Do(req) + if err != nil { + return pkg.PackageMeta{}, errors.Wrapf(err, "fetching package metadata from API for %s", pkgName) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return pkg.PackageMeta{}, errors.Errorf("unexpected status code %d when fetching package metadata", resp.StatusCode) + } + + var response packageListResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return pkg.PackageMeta{}, errors.Wrap(err, "decoding API response") + } + + switch len(response.Packages) { + case 0: + return pkg.PackageMeta{}, errors.Errorf("no package found with name %s", pkgName) + case 1: + metadata := convertAPIPackageToPackageMeta(response.Packages[0]) + return metadata, nil + default: + return pkg.PackageMeta{}, errors.Errorf("multiple packages found with name %s", pkgName) + } +} + +// ListPackageMetadata implements PackageMetadataProvider for registryAPIProvider +func (p *registryAPIProvider) ListPackageMetadata(ctx context.Context) ([]pkg.PackageMeta, error) { + var allPackages []pkg.PackageMeta + // Maximum allowed by the API (must be less than 500). Request up to 499 to + // account for pagination with minimum number of requests. + const limit = 499 + continuationToken := "" + + for { + url := fmt.Sprintf("%s/packages?limit=%d", p.apiURL, limit) + if continuationToken != "" { + url = fmt.Sprintf("%s&continuationToken=%s", url, continuationToken) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, errors.Wrap(err, "creating request for package list") + } + + resp, err := p.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "fetching package list from API") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("unexpected status code %d when fetching package list", resp.StatusCode) + } + + var response struct { + Packages []apiPackageMetadata `json:"packages"` + ContinuationToken *string `json:"continuationToken"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, errors.Wrap(err, "decoding API response") + } + + for _, apiPkg := range response.Packages { + metadata := convertAPIPackageToPackageMeta(apiPkg) + allPackages = append(allPackages, metadata) + } + + // If there's no continuation token, we've reached the end + if response.ContinuationToken == nil { + break + } + + continuationToken = *response.ContinuationToken + } + + return allPackages, nil +} diff --git a/tools/resourcedocsgen/pkg/registry/svc/metadata.go b/tools/resourcedocsgen/pkg/registry/svc/metadata.go new file mode 100644 index 0000000000..b2817762cc --- /dev/null +++ b/tools/resourcedocsgen/pkg/registry/svc/metadata.go @@ -0,0 +1,101 @@ +// Copyright 2024, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package svc + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" + + "github.com/pulumi/registry/tools/resourcedocsgen/pkg" +) + +// PackageMetadataProvider is an interface for providers that retrieve package metadata +// from either the filesystem directory or the Pulumi Registry API. +type PackageMetadataProvider interface { + // GetPackageMetadata returns metadata for a specific package + GetPackageMetadata(ctx context.Context, pkgName string) (pkg.PackageMeta, error) + // ListPackageMetadata returns metadata for all packages + ListPackageMetadata(ctx context.Context) ([]pkg.PackageMeta, error) +} + +// fileSystemProvider implements PackageMetadataProvider using the local yaml data files +// in the pulumi/registry repository. +type fileSystemProvider struct { + registryDir string +} + +// NewFileSystemProvider creates a new PackageMetadataProvider that reads from the filesystem +func NewFileSystemProvider(registryDir string) PackageMetadataProvider { + return &fileSystemProvider{ + registryDir: registryDir, + } +} + +func getRegistryPackagesPath(repoPath string) string { + return filepath.Join(repoPath, "themes", "default", "data", "registry", "packages") +} + +// GetPackageMetadata implements PackageMetadataProvider for fileSystemProvider +func (p *fileSystemProvider) GetPackageMetadata(ctx context.Context, pkgName string) (pkg.PackageMeta, error) { + metadataFilePath := filepath.Join(getRegistryPackagesPath(p.registryDir), pkgName+".yaml") + b, err := os.ReadFile(metadataFilePath) + if err != nil { + return pkg.PackageMeta{}, errors.Wrapf(err, "reading the metadata file %s", metadataFilePath) + } + + var metadata pkg.PackageMeta + if err := yaml.Unmarshal(b, &metadata); err != nil { + return pkg.PackageMeta{}, errors.Wrapf(err, "unmarshalling the metadata file %s", metadataFilePath) + } + + return metadata, nil +} + +// ListPackageMetadata implements PackageMetadataProvider for fileSystemProvider +func (p *fileSystemProvider) ListPackageMetadata(ctx context.Context) ([]pkg.PackageMeta, error) { + registryPackagesPath := getRegistryPackagesPath(p.registryDir) + files, err := os.ReadDir(registryPackagesPath) + if err != nil { + return nil, errors.Wrapf(err, "reading directory %s", registryPackagesPath) + } + + // Count YAML files to pre-allocate the slice mostly to appease the linter. + var metadataCount int + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") { + metadataCount++ + } + } + + metadataList := make([]pkg.PackageMeta, 0, metadataCount) + for _, file := range files { + if !strings.HasSuffix(file.Name(), ".yaml") { + continue + } + + metadata, err := p.GetPackageMetadata(ctx, strings.TrimSuffix(file.Name(), ".yaml")) + if err != nil { + return nil, err + } + metadataList = append(metadataList, metadata) + } + + return metadataList, nil +} diff --git a/tools/resourcedocsgen/pkg/registry/svc/metadata_test.go b/tools/resourcedocsgen/pkg/registry/svc/metadata_test.go new file mode 100644 index 0000000000..cbded109f0 --- /dev/null +++ b/tools/resourcedocsgen/pkg/registry/svc/metadata_test.go @@ -0,0 +1,382 @@ +// Copyright 2024, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package svc + +import ( + "context" + "encoding/json" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ghodss/yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pulumi/registry/tools/resourcedocsgen/pkg" +) + +func stringPtr(s string) *string { + return &s +} + +func TestConvertAPIPackageToPackageMeta(t *testing.T) { + t.Parallel() + createdAt := time.Date(2025, 4, 10, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + input apiPackageMetadata + want pkg.PackageMeta + wantErr bool + }{ + { + name: "provider package", + input: apiPackageMetadata{ + Name: "aws-test", + Publisher: "Pulumi", + Description: "AWS provider", + LogoURL: "https://example.com/logo.png", + RepoURL: "https://github.com/pulumi/pulumi-aws", + Category: "cloud", + IsFeatured: true, + PackageTypes: []string{"native"}, + SchemaURL: "https://example.com/schema.json", + Version: "v1.0.0", + Title: "AWS", + CreatedAt: createdAt, + }, + want: pkg.PackageMeta{ + Name: "aws-test", + Publisher: "Pulumi", + Description: "AWS provider", + LogoURL: "https://example.com/logo.png", + RepoURL: "https://github.com/pulumi/pulumi-aws", + Category: "cloud", + Featured: true, + Native: true, + Component: false, + SchemaFileURL: "https://example.com/schema.json", + Version: "v1.0.0", + Title: "AWS", + UpdatedOn: createdAt.Unix(), + }, + }, + { + name: "component package", + input: apiPackageMetadata{ + Name: "test-component", + PackageTypes: []string{"component"}, + CreatedAt: createdAt, + }, + want: pkg.PackageMeta{ + Name: "test-component", + Component: true, + Native: false, + UpdatedOn: createdAt.Unix(), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := convertAPIPackageToPackageMeta(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFileSystemProvider(t *testing.T) { + t.Parallel() + // Create a temporary directory for test files + tmpDir := t.TempDir() + packagesDir := filepath.Join(tmpDir, "themes", "default", "data", "registry", "packages") + require.NoError(t, os.MkdirAll(packagesDir, fs.ModePerm)) + + // Create test metadata files + testPackages := []pkg.PackageMeta{ + { + Name: "aws-test", + Publisher: "Pulumi", + Version: "v1.0.0", + UpdatedOn: time.Now().Unix(), + Native: true, + Component: false, + Category: "cloud", + Featured: true, + }, + { + Name: "kubernetes-test", + Publisher: "Pulumi", + Version: "v2.0.0", + UpdatedOn: time.Now().Unix(), + Native: true, + Component: false, + Category: "cloud", + Featured: true, + }, + } + + for _, p := range testPackages { + data, err := yaml.Marshal(p) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(packagesDir, p.Name+".yaml"), data, fs.ModePerm) + require.NoError(t, err) + } + + provider := NewFileSystemProvider(tmpDir) + + t.Run("GetPackageMetadata", func(t *testing.T) { + t.Parallel() + got, err := provider.GetPackageMetadata(context.Background(), "aws-test") + require.NoError(t, err) + assert.Equal(t, testPackages[0], got) + + _, err = provider.GetPackageMetadata(context.Background(), "non-existent-pkg-test") + assert.Error(t, err) + }) + + t.Run("ListPackageMetadata", func(t *testing.T) { + t.Parallel() + got, err := provider.ListPackageMetadata(context.Background()) + require.NoError(t, err) + assert.Len(t, got, len(testPackages)) + assert.ElementsMatch(t, testPackages, got) + }) + + t.Run("GetPackageMetadata errors", func(t *testing.T) { + t.Parallel() + t.Run("non-existent file", func(t *testing.T) { + provider := NewFileSystemProvider(tmpDir) + _, err := provider.GetPackageMetadata(context.Background(), "non-existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "reading the metadata file") + }) + + t.Run("invalid yaml", func(t *testing.T) { + t.Parallel() + + invalidDir := t.TempDir() + invalidPackagesDir := filepath.Join(invalidDir, "themes", "default", "data", "registry", "packages") + require.NoError(t, os.MkdirAll(invalidPackagesDir, fs.ModePerm)) + + err := os.WriteFile(filepath.Join(invalidPackagesDir, "invalid.yaml"), []byte("invalid yaml: ]["), fs.ModePerm) + require.NoError(t, err) + + provider := NewFileSystemProvider(invalidDir) + _, err = provider.GetPackageMetadata(context.Background(), "invalid") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unmarshalling the metadata file") + }) + }) + + t.Run("ListPackageMetadata errors", func(t *testing.T) { + t.Parallel() + t.Run("non-existent directory", func(t *testing.T) { + tmpDir := t.TempDir() + provider := NewFileSystemProvider(tmpDir) + _, err := provider.ListPackageMetadata(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "reading directory") + }) + + t.Run("ignores non-yaml files", func(t *testing.T) { + tmpDir := t.TempDir() + packagesDir := filepath.Join(tmpDir, "themes", "default", "data", "registry", "packages") + require.NoError(t, os.MkdirAll(packagesDir, fs.ModePerm)) + + // Create a valid yaml file + validPkg := pkg.PackageMeta{Name: "valid", Publisher: "Pulumi"} + validYAML, err := yaml.Marshal(validPkg) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(packagesDir, "valid.yaml"), validYAML, fs.ModePerm) + require.NoError(t, err) + + // Create an invalid yaml file + err = os.WriteFile(filepath.Join(packagesDir, "readme.txt"), []byte("not valid yaml"), fs.ModePerm) + require.NoError(t, err) + + provider := NewFileSystemProvider(tmpDir) + packages, err := provider.ListPackageMetadata(context.Background()) + require.NoError(t, err) + assert.Len(t, packages, 1) + assert.Equal(t, "valid", packages[0].Name) + }) + }) +} + +func TestRegistryAPIProvider(t *testing.T) { + t.Parallel() + t.Run("GetPackageMetadata", func(t *testing.T) { + t.Parallel() + t.Run("successfully returns package metadata", func(t *testing.T) { + t.Parallel() + // configure test server + expectedPackage := apiPackageMetadata{ + Name: "aws-test", + Publisher: "Pulumi", + Version: "v1.0.0", + CreatedAt: time.Now(), + } + responseBody := packageListResponse{ + Packages: []apiPackageMetadata{expectedPackage}, + } + jsonResponse, err := json.Marshal(responseBody) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/packages", r.URL.Path) + assert.Equal(t, "name=aws-test", r.URL.RawQuery) + w.WriteHeader(http.StatusOK) + _, err = w.Write(jsonResponse) + require.NoError(t, err) + })) + defer server.Close() + + // rreate client using test server + provider := ®istryAPIProvider{ + apiURL: server.URL, + client: http.DefaultClient, + } + + got, err := provider.GetPackageMetadata(context.Background(), "aws-test") + require.NoError(t, err) + assert.Equal(t, "aws-test", got.Name) + assert.Equal(t, "Pulumi", got.Publisher) + }) + + t.Run("non existent package", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + provider := ®istryAPIProvider{ + apiURL: server.URL, + client: http.DefaultClient, + } + + _, err := provider.GetPackageMetadata(context.Background(), "non-existent-pkg") + assert.Error(t, err) + }) + + t.Run("multiple packages found", func(t *testing.T) { + // Setup response with multiple packages + response := packageListResponse{ + Packages: []apiPackageMetadata{ + {Name: "aws-test", Publisher: "Pulumi"}, + {Name: "aws-test", Publisher: "Community"}, + }, + } + jsonResponse, err := json.Marshal(response) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, err = w.Write(jsonResponse) + require.NoError(t, err) + })) + defer server.Close() + + provider := ®istryAPIProvider{ + apiURL: server.URL, + client: http.DefaultClient, + } + + _, err = provider.GetPackageMetadata(context.Background(), "aws-test") + assert.Error(t, err) + assert.Contains(t, err.Error(), "multiple packages found") + }) + }) + + t.Run("ListPackageMetadata", func(t *testing.T) { + t.Parallel() + t.Run("successfully lists packages with pagination", func(t *testing.T) { + t.Parallel() + // configure to test with pagination + firstPage := struct { + Packages []apiPackageMetadata `json:"packages"` + ContinuationToken *string `json:"continuationToken"` + }{ + Packages: []apiPackageMetadata{ + {Name: "aws-test", Publisher: "Pulumi"}, + {Name: "azure-test", Publisher: "Pulumi"}, + }, + ContinuationToken: stringPtr("page2"), + } + firstPageJSON, err := json.Marshal(firstPage) + require.NoError(t, err) + + secondPage := struct { + Packages []apiPackageMetadata `json:"packages"` + ContinuationToken *string `json:"continuationToken"` + }{ + Packages: []apiPackageMetadata{ + {Name: "gcp-test", Publisher: "Pulumi"}, + }, + ContinuationToken: nil, + } + secondPageJSON, err := json.Marshal(secondPage) + require.NoError(t, err) + + // setup test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/packages", r.URL.Path) + w.WriteHeader(http.StatusOK) + + // Return different responses based on continuation token + if r.URL.Query().Get("continuationToken") == "page2" { + _, err = w.Write(secondPageJSON) + require.NoError(t, err) + } else { + _, err = w.Write(firstPageJSON) + require.NoError(t, err) + } + })) + defer server.Close() + + provider := ®istryAPIProvider{ + apiURL: server.URL, + client: http.DefaultClient, + } + + got, err := provider.ListPackageMetadata(context.Background()) + require.NoError(t, err) + assert.Len(t, got, 3) + assert.Equal(t, []string{"aws-test", "azure-test", "gcp-test"}, []string{got[0].Name, got[1].Name, got[2].Name}) + }) + t.Run("server error", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + provider := ®istryAPIProvider{ + apiURL: server.URL, + client: http.DefaultClient, + } + + _, err := provider.ListPackageMetadata(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status code 500") + }) + }) +}