Skip to content

Commit

Permalink
apiviewgo parses non-SDK modules as needed (#8699)
Browse files Browse the repository at this point in the history
  • Loading branch information
chlowell authored Aug 1, 2024
1 parent a1c9993 commit 286da43
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 7 deletions.
21 changes: 21 additions & 0 deletions src/go/cmd/api_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,27 @@ func TestDiagnostics(t *testing.T) {
}
}

func TestExternalModule(t *testing.T) {
review, err := createReview(filepath.Clean("testdata/test_external_module"))
require.NoError(t, err)
require.Equal(t, 1, len(review.Diagnostics))
require.Equal(t, aliasFor+"github.com/Azure/azure-sdk-for-go/sdk/azcore.Policy", review.Diagnostics[0].Text)
require.Equal(t, 1, len(review.Navigation))
require.Equal(t, 1, len(review.Navigation[0].ChildItems))
foundDo, foundPolicy := false, false
for _, token := range review.Tokens {
if token.DefinitionID != nil && *token.DefinitionID == "test_external_module.MyPolicy" {
require.Equal(t, "MyPolicy", token.Value)
foundPolicy = true
} else if token.Value == "Do" {
foundDo = true
require.Contains(t, *token.DefinitionID, "MyPolicy")
}
}
require.True(t, foundDo, "missing MyPolicy.Do()")
require.True(t, foundPolicy, "missing MyPolicy type")
}

func TestAliasDefinitions(t *testing.T) {
for _, test := range []struct {
name, path, sourceName string
Expand Down
151 changes: 151 additions & 0 deletions src/go/cmd/download.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"time"

"golang.org/x/mod/module"
"golang.org/x/mod/zip"
)

var errCachedModuleNotFound = errors.New("cached module not found")

// GetExternalModule returns a Module representing mod. When GOMODCACHE is set,
// it looks for mod's source in the mod cache. Otherwise, it downloads mod from
// the module proxy.
func GetExternalModule(mod module.Version) (*Module, error) {
m, err := cachedModule(mod)
if err != nil && !errors.Is(err, errCachedModuleNotFound) {
return nil, fmt.Errorf("failed to parse cached module %s: %w", mod.Path, err)
}
if m == nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
m, err = downloadModule(ctx, mod)
}
return m, err
}

// downloadModule downloads mod from the Go module proxy, storing it in a temporary directory.
// That directory looks like:
//
// ~/apiviewgo{random suffix}
// ├── [email protected]
// │ └── github.com/!azure/azure-sdk-for-go/sdk/[email protected]
// │ └── go.mod
// └── zip
// └── github.com/!azure/azure-sdk-for-go/sdk/azcore
// └── v1.0.0.zip
//
// This is perhaps odd but zip.Unzip() requires the target directory be entirely empty, so
// obvious tidier schemes are impossible. Although downloadModule could in principle unzip
// modules to the local Go module cache, it doesn't do so to avoid affecting other Go programs
// or reimplementing whatever `go mod download` behavior is necessary to ensure correctness.
func downloadModule(ctx context.Context, mod module.Version) (*Module, error) {
d, err := downloadDir()
if err != nil {
return nil, err
}
// We don't keep downloaded content because an apiviewgo instance doesn't need to download
// any mod twice (Review caches the Module this function returns) and we don't want to
// maintain a cache given the low performance impact of downloading modules (very few SDK
// modules export types defined elsewhere).
defer func() {
if err := os.RemoveAll(d); err != nil {
fmt.Fprintf(os.Stderr, "failed to remove download directory %s: %v", d, err)
}
}()
escaped, err := module.EscapePath(mod.Path)
if err != nil {
return nil, fmt.Errorf("unescapeable module path %q: %w", mod.Path, err)
}
u, err := url.Parse("https://" + path.Join("proxy.golang.org", escaped, "@v", mod.Version+".zip"))
if err != nil {
return nil, fmt.Errorf("failed to parse module URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download module zip from %s: %w", u, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg := "module proxy responded %d"
if resp.Body != nil {
b, _ := io.ReadAll(resp.Body)
msg += ": " + string(b)
}
return nil, fmt.Errorf(msg, resp.StatusCode)
}
zp := filepath.Join(d, "zip", mustEscape(mod.Path), mod.Version+".zip")
err = os.MkdirAll(filepath.Dir(zp), 0700)
if err != nil {
return nil, fmt.Errorf("failed to create directory for %s: %w", zp, err)
}
f, err := os.Create(zp)
if err != nil {
return nil, fmt.Errorf("failed to create %s: %w", zp, err)
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to write %s: %w", zp, err)
}
modver := path.Base(mod.Path) + "@" + mod.Version
p := filepath.Join(d, modver, mustEscape(mod.Path)) + "@" + mod.Version
err = zip.Unzip(p, mod, zp)
if err != nil {
return nil, fmt.Errorf("failed to unzip %s: %w", zp, err)
}
return NewModule(p)
}

// cachedModule returns a Module for mod if it's in either the local Go mod
// cache or apiviewgo cache. It returns errCachedModuleNotFound when the
// module isn't in either cache.
func cachedModule(mod module.Version) (*Module, error) {
if modCache := os.Getenv("GOMODCACHE"); modCache != "" {
d := filepath.Join(modCache, mustEscape(mod.Path)) + "@" + mod.Version
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
return NewModule(d)
}
}
return nil, errCachedModuleNotFound
}

// mustEscape escapes modPath. It panics when that fails.
func mustEscape(modPath string) string {
escaped, err := module.EscapePath(modPath)
if err != nil {
panic(fmt.Errorf("failed to escape module path: %w", err))
}
return escaped
}

// downloadDir creates a directory to store downloaded module zips and source.
// Callers are responsible for removing the directory when they're done with it.
func downloadDir() (string, error) {
root, err := os.UserHomeDir()
if err != nil {
root = os.TempDir()
}
d, err := os.MkdirTemp(root, "apiviewgo")
if err != nil {
err = fmt.Errorf("failed to create download directory: %w", err)
}
return d, err
}
7 changes: 6 additions & 1 deletion src/go/cmd/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,14 @@ func NewModule(dir string) (*Module, error) {
if err != nil {
return nil, err
}
name := filepath.Base(dir)
if before, _, found := strings.Cut(name, "@"); found {
// dir is in the module cache, something like "/home/me/go/pkg/mod/github.com/Foo/[email protected]"
name = before
}
m := Module{
ModFile: mf,
Name: filepath.Base(dir),
Name: name,
Packages: map[string]*Pkg{},
}

Expand Down
2 changes: 1 addition & 1 deletion src/go/cmd/pkg.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func NewPkg(dir, modulePath string) (*Pkg, error) {
moduleName := filepath.Base(modulePathWithoutVersion)
if _, after, found := strings.Cut(dir, moduleName); found {
pk.relName = moduleName
if after != "" {
if after != "" && after[0] != '@' {
pk.relName += after
}
pk.relName = strings.ReplaceAll(pk.relName, "\\", "/")
Expand Down
17 changes: 12 additions & 5 deletions src/go/cmd/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,23 +178,30 @@ func (r *Review) resolveAliases() error {
)
if m, ok = r.modules[ta.SourceMod.Path]; !ok {
m, err = r.findLocalModule(*ta)
if errors.Is(err, errExternalModule) {
m, err = GetExternalModule(ta.SourceMod)
}
if err == nil {
err = r.AddModule(m)
}
// TODO: handle errExternalModule by acquiring the module code from the external repository.
// For now, include the aliased type in the review without its definition and add a diagnostic.
if err != nil && !errors.Is(err, errExternalModule) {
if err != nil {
return err
}
}
def := typeDef{}
if m != nil {
impPath := ta.QualifiedName[:strings.LastIndex(ta.QualifiedName, ".")]
dot := strings.LastIndex(ta.QualifiedName, ".")
if len(ta.QualifiedName)-2 < dot || dot < 1 {
// there must be at least one rune before and after the dot
panic(fmt.Sprintf("alias %q refers to an invalid qualified name %q", ta.Name, ta.QualifiedName))
}
impPath := ta.QualifiedName[:dot]
p, ok := m.Packages[impPath]
if !ok {
return fmt.Errorf("couldn't find definition for " + ta.Name)
}
if d, ok := recursiveFindTypeDef(ta.Name, p, m.Packages); ok {
sourceName := ta.QualifiedName[dot+1:]
if d, ok := recursiveFindTypeDef(sourceName, p, m.Packages); ok {
def = d
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/go/cmd/testdata/test_external_module/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module test_external_module

go 1.18

require github.com/Azure/azure-sdk-for-go/sdk/azcore v0.1.0
5 changes: 5 additions & 0 deletions src/go/cmd/testdata/test_external_module/test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package test_external_module

import "github.com/Azure/azure-sdk-for-go/sdk/azcore"

type MyPolicy = azcore.Policy

0 comments on commit 286da43

Please sign in to comment.