Skip to content

Commit def4342

Browse files
sean1588iwahbe
authored andcommitted
Enable fetching package metadata from pulumi registry api
1 parent ef104a5 commit def4342

File tree

3 files changed

+663
-44
lines changed

3 files changed

+663
-44
lines changed

tools/resourcedocsgen/cmd/docs/registry.go

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515
package docs
1616

1717
import (
18+
"context"
1819
"encoding/json"
1920
"fmt"
2021
"io"
2122
"net/http"
2223
"net/url"
23-
"os"
2424
"path/filepath"
2525
"runtime"
2626
"strings"
@@ -36,6 +36,7 @@ import (
3636
pschema "github.com/pulumi/pulumi/pkg/v3/codegen/schema"
3737
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
3838
"github.com/pulumi/registry/tools/resourcedocsgen/pkg"
39+
"github.com/pulumi/registry/tools/resourcedocsgen/pkg/registry/svc"
3940
concpool "github.com/sourcegraph/conc/pool"
4041
)
4142

@@ -132,89 +133,69 @@ func getSchemaFileURL(metadata pkg.PackageMeta) (string, error) {
132133
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repoSlug, metadata.Version, schemaFilePath), nil
133134
}
134135

135-
func getRegistryPackagesPath(repoPath string) string {
136-
return filepath.Join(repoPath, "themes", "default", "data", "registry", "packages")
137-
}
138-
139-
func genResourceDocsForAllRegistryPackages(registryRepoPath, baseDocsOutDir, basePackageTreeJSONOutDir string) error {
140-
registryPackagesPath := getRegistryPackagesPath(registryRepoPath)
141-
metadataFiles, err := os.ReadDir(registryPackagesPath)
136+
func genResourceDocsForAllRegistryPackages(
137+
ctx context.Context,
138+
provider svc.PackageMetadataProvider,
139+
baseDocsOutDir, basePackageTreeJSONOutDir string,
140+
) error {
141+
metadataList, err := provider.ListPackageMetadata(ctx)
142142
if err != nil {
143-
return errors.Wrap(err, "reading the registry packages dir")
143+
return errors.Wrap(err, "listing package metadata")
144144
}
145145

146146
pool := concpool.New().WithErrors().WithMaxGoroutines(runtime.NumCPU())
147-
for _, f := range metadataFiles {
148-
f := f
147+
for _, metadata := range metadataList {
149148
pool.Go(func() error {
150-
glog.Infof("=== starting %s ===\n", f.Name())
151-
glog.Infoln("Processing metadata file")
152-
metadataFilePath := filepath.Join(registryPackagesPath, f.Name())
153-
154-
b, err := os.ReadFile(metadataFilePath)
155-
if err != nil {
156-
return errors.Wrapf(err, "reading the metadata file %s", metadataFilePath)
157-
}
158-
159-
var metadata pkg.PackageMeta
160-
if err := yaml.Unmarshal(b, &metadata); err != nil {
161-
return errors.Wrapf(err, "unmarshalling the metadata file %s", metadataFilePath)
162-
}
163-
149+
glog.Infof("=== starting %s ===\n", metadata.Name)
164150
docsOutDir := filepath.Join(baseDocsOutDir, metadata.Name, "api-docs")
165151
err = genResourceDocsForPackageFromRegistryMetadata(metadata, docsOutDir, basePackageTreeJSONOutDir)
166152
if err != nil {
167-
return errors.Wrapf(err, "generating resource docs using metadata file info %s", f.Name())
153+
return errors.Wrapf(err, "generating resource docs using metadata file info %s", metadata.Name)
168154
}
169155

170-
glog.Infof("=== completed %s ===", f.Name())
156+
glog.Infof("=== completed %s ===", metadata.Name)
171157
return nil
172158
})
173159
}
174-
175160
return pool.Wait()
176161
}
177162

178163
func resourceDocsFromRegistryCmd() *cobra.Command {
179164
var baseDocsOutDir string
180165
var basePackageTreeJSONOutDir string
181166
var registryDir string
167+
var useAPI bool
168+
var apiURL string
182169

183170
cmd := &cobra.Command{
184171
Use: "registry [pkgName]",
185172
Short: "Generate resource docs for a package from the registry",
186173
Long: "Generate resource docs for all packages in the registry or specific packages. " +
187174
"Pass a package name in the registry as an optional arg to generate docs only for that package.",
188175
RunE: func(cmd *cobra.Command, args []string) error {
189-
registryDir, err := filepath.Abs(registryDir)
190-
if err != nil {
191-
return errors.Wrap(err, "finding the cwd")
176+
ctx := cmd.Context()
177+
var provider svc.PackageMetadataProvider
178+
if useAPI {
179+
provider = svc.NewAPIProvider(apiURL)
180+
} else {
181+
provider = svc.NewFileSystemProvider(registryDir)
192182
}
193183

194184
if len(args) > 0 {
195185
glog.Infoln("Generating docs for a single package:", args[0])
196-
registryPackagesPath := getRegistryPackagesPath(registryDir)
197-
pkgName := args[0]
198-
metadataFilePath := filepath.Join(registryPackagesPath, pkgName+".yaml")
199-
b, err := os.ReadFile(metadataFilePath)
186+
metadata, err := provider.GetPackageMetadata(ctx, args[0])
200187
if err != nil {
201-
return errors.Wrapf(err, "reading the metadata file %s", metadataFilePath)
202-
}
203-
204-
var metadata pkg.PackageMeta
205-
if err := yaml.Unmarshal(b, &metadata); err != nil {
206-
return errors.Wrapf(err, "unmarshalling the metadata file %s", metadataFilePath)
188+
return errors.Wrapf(err, "getting metadata for package %q", args[0])
207189
}
208190

209191
docsOutDir := filepath.Join(baseDocsOutDir, metadata.Name, "api-docs")
210-
211192
err = genResourceDocsForPackageFromRegistryMetadata(metadata, docsOutDir, basePackageTreeJSONOutDir)
212193
if err != nil {
213-
return errors.Wrapf(err, "generating docs for package %q from registry metadata", pkgName)
194+
return errors.Wrapf(err, "generating docs for package %q from registry metadata", args[0])
214195
}
215196
} else {
216197
glog.Infoln("Generating docs for all packages in the registry...")
217-
err := genResourceDocsForAllRegistryPackages(registryDir, baseDocsOutDir, basePackageTreeJSONOutDir)
198+
err := genResourceDocsForAllRegistryPackages(ctx, provider, baseDocsOutDir, basePackageTreeJSONOutDir)
218199
if err != nil {
219200
return errors.Wrap(err, "generating docs for all packages from registry metadata")
220201
}
@@ -234,6 +215,10 @@ func resourceDocsFromRegistryCmd() *cobra.Command {
234215
cmd.Flags().StringVar(&registryDir, "registryDir",
235216
".",
236217
"The root of the pulumi/registry directory")
218+
cmd.Flags().BoolVar(&useAPI, "use-api", false, "Use the Pulumi Registry API instead of local files")
219+
cmd.Flags().StringVar(&apiURL, "api-url",
220+
"https://api.pulumi.com/api/preview/registry",
221+
"URL of the Pulumi Registry API")
237222

238223
return cmd
239224
}
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
// Copyright 2024, Pulumi Corporation.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package svc
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"net/http"
22+
"os"
23+
"path/filepath"
24+
"slices"
25+
"strings"
26+
"time"
27+
28+
"github.com/ghodss/yaml"
29+
"github.com/pkg/errors"
30+
31+
"github.com/pulumi/registry/tools/resourcedocsgen/pkg"
32+
)
33+
34+
// PackageMetadataProvider is an interface for providers that retrieve package metadata
35+
// from either the filesystem directory or the Pulumi Registry API.
36+
type PackageMetadataProvider interface {
37+
// GetPackageMetadata returns metadata for a specific package
38+
GetPackageMetadata(ctx context.Context, pkgName string) (pkg.PackageMeta, error)
39+
// ListPackageMetadata returns metadata for all packages
40+
ListPackageMetadata(ctx context.Context) ([]pkg.PackageMeta, error)
41+
}
42+
43+
// fileSystemProvider implements PackageMetadataProvider using the local yaml data files
44+
// in the pulumi/registry repository.
45+
type fileSystemProvider struct {
46+
registryDir string
47+
}
48+
49+
// registryAPIProvider implements PackageMetadataProvider using the Pulumi API
50+
// to retrieve package metadata.
51+
type registryAPIProvider struct {
52+
apiURL string
53+
client *http.Client
54+
}
55+
56+
// PackageMetadata represents the API response structure for package metadata
57+
// from the Pulumi Registry API.
58+
// TODO: will eventually be available in the "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" package.
59+
type PackageMetadata struct {
60+
Name string `json:"name"`
61+
Publisher string `json:"publisher"`
62+
Source string `json:"source"`
63+
Version string `json:"version"`
64+
Title string `json:"title,omitempty"`
65+
Description string `json:"description,omitempty"`
66+
LogoURL string `json:"logoUrl,omitempty"`
67+
RepoURL string `json:"repoUrl,omitempty"`
68+
Category string `json:"category,omitempty"`
69+
IsFeatured bool `json:"isFeatured"`
70+
PackageTypes []string `json:"packageTypes,omitempty"`
71+
PackageStatus string `json:"packageStatus"`
72+
SchemaURL string `json:"schemaURL"`
73+
CreatedAt time.Time `json:"createdAt"`
74+
}
75+
76+
// PackageListResponse represents the API response structure for package lists
77+
type PackageListResponse struct {
78+
Packages []PackageMetadata `json:"packages"`
79+
}
80+
81+
// NewFileSystemProvider creates a new PackageMetadataProvider that reads from the filesystem
82+
func NewFileSystemProvider(registryDir string) PackageMetadataProvider {
83+
return &fileSystemProvider{
84+
registryDir: registryDir,
85+
}
86+
}
87+
88+
// NewAPIProvider creates a new PackageMetadataProvider that reads from the Pulumi API
89+
func NewAPIProvider(apiURL string) PackageMetadataProvider {
90+
return &registryAPIProvider{
91+
apiURL: apiURL,
92+
client: http.DefaultClient,
93+
}
94+
}
95+
96+
func getRegistryPackagesPath(repoPath string) string {
97+
return filepath.Join(repoPath, "themes", "default", "data", "registry", "packages")
98+
}
99+
100+
func convertAPIPackageToPackageMeta(apiPkg PackageMetadata) pkg.PackageMeta {
101+
return pkg.PackageMeta{
102+
Name: apiPkg.Name,
103+
Publisher: apiPkg.Publisher,
104+
Description: apiPkg.Description,
105+
LogoURL: apiPkg.LogoURL,
106+
RepoURL: apiPkg.RepoURL,
107+
Category: pkg.PackageCategory(apiPkg.Category),
108+
Featured: apiPkg.IsFeatured,
109+
Native: slices.Contains(apiPkg.PackageTypes, "native"),
110+
Component: slices.Contains(apiPkg.PackageTypes, "component"),
111+
PackageStatus: pkg.PackageStatus(apiPkg.PackageStatus),
112+
SchemaFileURL: apiPkg.SchemaURL,
113+
Version: apiPkg.Version,
114+
Title: apiPkg.Title,
115+
UpdatedOn: apiPkg.CreatedAt.Unix(),
116+
}
117+
}
118+
119+
// GetPackageMetadata implements PackageMetadataProvider for fileSystemProvider
120+
func (p *fileSystemProvider) GetPackageMetadata(ctx context.Context, pkgName string) (pkg.PackageMeta, error) {
121+
metadataFilePath := filepath.Join(getRegistryPackagesPath(p.registryDir), pkgName+".yaml")
122+
b, err := os.ReadFile(metadataFilePath)
123+
if err != nil {
124+
return pkg.PackageMeta{}, errors.Wrapf(err, "reading the metadata file %s", metadataFilePath)
125+
}
126+
127+
var metadata pkg.PackageMeta
128+
if err := yaml.Unmarshal(b, &metadata); err != nil {
129+
return pkg.PackageMeta{}, errors.Wrapf(err, "unmarshalling the metadata file %s", metadataFilePath)
130+
}
131+
132+
return metadata, nil
133+
}
134+
135+
// ListPackageMetadata implements PackageMetadataProvider for fileSystemProvider
136+
func (p *fileSystemProvider) ListPackageMetadata(ctx context.Context) ([]pkg.PackageMeta, error) {
137+
registryPackagesPath := getRegistryPackagesPath(p.registryDir)
138+
files, err := os.ReadDir(registryPackagesPath)
139+
if err != nil {
140+
return nil, errors.Wrapf(err, "reading directory %s", registryPackagesPath)
141+
}
142+
143+
// Count YAML files to pre-allocate the slice mostly to appease the linter.
144+
var metadataCount int
145+
for _, file := range files {
146+
if strings.HasSuffix(file.Name(), ".yaml") {
147+
metadataCount++
148+
}
149+
}
150+
151+
metadataList := make([]pkg.PackageMeta, 0, metadataCount)
152+
for _, file := range files {
153+
if !strings.HasSuffix(file.Name(), ".yaml") {
154+
continue
155+
}
156+
157+
metadata, err := p.GetPackageMetadata(ctx, strings.TrimSuffix(file.Name(), ".yaml"))
158+
if err != nil {
159+
return nil, err
160+
}
161+
metadataList = append(metadataList, metadata)
162+
}
163+
164+
return metadataList, nil
165+
}
166+
167+
// GetPackageMetadata implements PackageMetadataProvider for registryAPIProvider
168+
func (p *registryAPIProvider) GetPackageMetadata(ctx context.Context, pkgName string) (pkg.PackageMeta, error) {
169+
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
170+
fmt.Sprintf("%s/packages?name=%s", p.apiURL, pkgName), nil)
171+
if err != nil {
172+
return pkg.PackageMeta{}, errors.Wrapf(err, "creating request for package %s", pkgName)
173+
}
174+
175+
resp, err := p.client.Do(req)
176+
if err != nil {
177+
return pkg.PackageMeta{}, errors.Wrapf(err, "fetching package metadata from API for %s", pkgName)
178+
}
179+
defer resp.Body.Close()
180+
181+
if resp.StatusCode != http.StatusOK {
182+
return pkg.PackageMeta{}, errors.Errorf("unexpected status code %d when fetching package metadata", resp.StatusCode)
183+
}
184+
185+
var response PackageListResponse
186+
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
187+
return pkg.PackageMeta{}, errors.Wrap(err, "decoding API response")
188+
}
189+
190+
switch len(response.Packages) {
191+
case 0:
192+
return pkg.PackageMeta{}, errors.Errorf("no package found with name %s", pkgName)
193+
case 1:
194+
metadata := convertAPIPackageToPackageMeta(response.Packages[0])
195+
return metadata, nil
196+
default:
197+
return pkg.PackageMeta{}, errors.Errorf("multiple packages found with name %s", pkgName)
198+
}
199+
}
200+
201+
// ListPackageMetadata implements PackageMetadataProvider for registryAPIProvider
202+
func (p *registryAPIProvider) ListPackageMetadata(ctx context.Context) ([]pkg.PackageMeta, error) {
203+
var allPackages []pkg.PackageMeta
204+
// Maximum allowed by the API (must be less than 500). Request up to 499 to
205+
// account for pagination with minimum number of requests.
206+
const limit = 499
207+
continuationToken := ""
208+
209+
for {
210+
url := fmt.Sprintf("%s/packages?limit=%d", p.apiURL, limit)
211+
if continuationToken != "" {
212+
url = fmt.Sprintf("%s&continuationToken=%s", url, continuationToken)
213+
}
214+
215+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
216+
if err != nil {
217+
return nil, errors.Wrap(err, "creating request for package list")
218+
}
219+
220+
resp, err := p.client.Do(req)
221+
if err != nil {
222+
return nil, errors.Wrap(err, "fetching package list from API")
223+
}
224+
defer resp.Body.Close()
225+
226+
if resp.StatusCode != http.StatusOK {
227+
return nil, errors.Errorf("unexpected status code %d when fetching package list", resp.StatusCode)
228+
}
229+
230+
var response struct {
231+
Packages []PackageMetadata `json:"packages"`
232+
ContinuationToken *string `json:"continuationToken"`
233+
}
234+
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
235+
return nil, errors.Wrap(err, "decoding API response")
236+
}
237+
238+
for _, apiPkg := range response.Packages {
239+
metadata := convertAPIPackageToPackageMeta(apiPkg)
240+
allPackages = append(allPackages, metadata)
241+
}
242+
243+
// If there's no continuation token, we've reached the end
244+
if response.ContinuationToken == nil {
245+
break
246+
}
247+
248+
continuationToken = *response.ContinuationToken
249+
}
250+
251+
return allPackages, nil
252+
}

0 commit comments

Comments
 (0)