diff --git a/README.md b/README.md index 9b2d6fe3..49ae4d0e 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ output is no terminal, such as in a CI job. GitHub Actions, GitLab CI and Azure without being asked, and the log of a building block run stays plain, since meshPanel shows it as plain text. `meshstack api` sends an authenticated, `curl`-like request to any path of the API. For a meshObject -endpoint it picks the latest version the [OpenAPI docs](https://docs.meshcloud.io/api/introduction/) list, -and `meshstack api-docs` shows what those docs say about a request. +endpoint it picks the latest version the [OpenAPI docs](https://docs.meshcloud.io/api/introduction/) of your +meshStack list, and `meshstack api-docs` shows what those docs say about a request. ![meshstack login picks a profile, logs in through the browser and selects a workspace, meshstack buildingblock list lists the first building blocks, meshstack api sends that request as it is, the same command line with api-docs in place of api describes it, as meshstack api-docs --describe bb.list does, meshstack profile edits a profile, and meshstack profile add completes the endpoint and the workspace of the logged-in profile](docs/demo.gif) diff --git a/cmd/api/api.go b/cmd/api/api.go index 541c710c..727c3640 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -71,7 +71,7 @@ To see what the API docs say about the request, run the same command line with ' selector.Path = r.target.Path args = []string{r.target.String()} } - spec, wait, err := flags.loadApiDocs(cmd) + spec, wait, err := flags.loadApiDocs(cmd, meshStack.Endpoint) defer wait() if err != nil { slog.WarnContext(ctx, fmt.Sprintf("Sending the request unchanged, because the API docs are not available: %s. "+ diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 81c7729b..0d7d83c5 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/cmd/internal/markdown" ) @@ -43,7 +44,9 @@ Give the request as you would give it to 'meshstack api': a path or a URL, with --%[6]s %[7]s prints the matching part of the OpenAPI document as JSON. ---%[8]s uses the API docs of meshStack's development version.`, +The command downloads the API docs from your meshStack, so they list only what your meshStack offers. If no endpoint is set, or if your meshStack is too old to serve its API docs, the command uses the API docs of the latest release on docs.meshcloud.io. + +--%[8]s uses the API docs of meshStack's development version instead.`, methodFlagName, headerFlagName, requestJsonFlagName, apiVersionFlagName, describeFlagName, internal.OutputFlagName, internal.OutputJson, devFlagName), Example: ` meshstack api-docs @@ -63,10 +66,10 @@ Give the request as you would give it to 'meshstack api': a path or a URL, with if describe != "" && len(args) > 0 { return fmt.Errorf("--%s cannot be combined with a path; see --help", describeFlagName) } + var endpoint xurl.URL if r.target.IsAbs() { - endpoint, _, profileErr := profileFor(cmd.Context(), r.target) - if profileErr != nil { - return profileErr + if endpoint, _, err = profileFor(cmd.Context(), r.target); err != nil { + return err } if endpoint.URL == nil { return fmt.Errorf("no stored profile matches %s; give the path relative to the endpoint", r.target.Redacted()) @@ -74,12 +77,14 @@ Give the request as you would give it to 'meshstack api': a path or a URL, with if err = r.cutTo(endpoint); err != nil { return err } + } else if endpoint, err = docsEndpoint(cmd.Context()); err != nil { + return err } selector, err := flags.selector(r) if err != nil { return err } - spec, wait, err := flags.loadApiDocs(cmd) + spec, wait, err := flags.loadApiDocs(cmd, endpoint) defer wait() if err != nil { return err diff --git a/cmd/api/profile.go b/cmd/api/profile.go index aa847917..7d520a8a 100644 --- a/cmd/api/profile.go +++ b/cmd/api/profile.go @@ -83,6 +83,22 @@ func profileFor(ctx context.Context, requestURL *url.URL) (endpoint xurl.URL, so }}}, nil } +func docsEndpoint(ctx context.Context) (xurl.URL, error) { + settingSources := internal.SettingSources() + var storedEndpoint setting.Sources + current, _, err := profile.ResolveProfile(ctx, profile.ResolveProfileOptions{SettingSources: settingSources, StoredOnly: true}) + if err == nil { + storedEndpoint = setting.Sources{current.EndpointSource()} + } else if !errors.Is(err, profile.ErrNoStoredProfile) { + return xurl.URL{}, err + } + endpoint, err := settingSources.ResolveSetting(ctx, meshstack.EndpointSetting, storedEndpoint...) + if errors.Is(err, setting.ErrNoSourceProvidedValue) { + return xurl.URL{}, nil + } + return endpoint, err +} + func notHeldError(profiles profile.Profiles, requestURL *url.URL, endpoint xurl.URL, ofProfile, source string) error { urlEndpoint, _ := profiles.EndpointHolding(requestURL) return fmt.Errorf("%s is under endpoint %s, which does not match the endpoint %s%s from %s; leave out %s, or give the path relative to %s", diff --git a/cmd/api/request.go b/cmd/api/request.go index ff2c4f59..27194e1c 100644 --- a/cmd/api/request.go +++ b/cmd/api/request.go @@ -124,16 +124,17 @@ func (f *requestFlags) selector(r request) (openapi.Selector, error) { return selector, nil } -func (f *requestFlags) loadApiDocs(cmd *cobra.Command) (openapi.Spec, func(), error) { +func (f *requestFlags) loadApiDocs(cmd *cobra.Command, endpoint xurl.URL) (openapi.Spec, func(), error) { httpClient, err := internal.ResolveClientOptions().HttpClient() if err != nil { return openapi.Spec{}, func() {}, err } return apidocs.Load(cmd.Context(), apidocs.Options{ - Sources: internal.SettingSources(), - Client: httpClient, - Version: internal.Version, - Dev: f.dev, + Sources: internal.SettingSources(), + Client: httpClient, + Endpoint: endpoint, + Version: internal.Version, + Dev: f.dev, }) } diff --git a/internal/apidocs/apidocs.go b/internal/apidocs/apidocs.go index 938ccf30..cdf5d27c 100644 --- a/internal/apidocs/apidocs.go +++ b/internal/apidocs/apidocs.go @@ -6,8 +6,11 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/hex" "errors" "fmt" + "log/slog" + gourl "net/url" "os" "strings" @@ -32,7 +35,8 @@ const urlEnv = "MESHSTACK_API_DOCS_URL" type Options struct { setting.Sources - Client http.Client + Client http.Client + Endpoint xurl.URL // Version is the CLI's. A build of no release reads the docs of develop, as Dev does. Version string Dev bool @@ -66,22 +70,38 @@ func Load(ctx context.Context, opts Options) (spec openapi.Spec, wait func(), er ErrNoConfigDir, dir, config.DirectorySetting.EnvKey()) } - url, path, conditionalGet := releasedUrl, dir.Join("api-docs.json"), false - if cliVersion, parseErr := version.Parse(opts.Version); opts.Dev || parseErr != nil || !cliVersion.IsRelease() { - url, path, conditionalGet = devUrl, dir.Join("api-docs-dev.json"), true - } if customEnv != "" { var custom xurl.URL if err = custom.UnmarshalText([]byte(customEnv)); err != nil { return spec, wait, fmt.Errorf("%s: %w", urlEnv, err) } - // One file per URL, because the conditional GET asks only for a document newer than the stored - // one: after a switch to the docs of an older meshStack, it would keep those of the newer one. - urlHash := sha256.Sum256([]byte(custom.String())) - url, path, conditionalGet = custom, dir.Join(fmt.Sprintf("api-docs-custom-%x.json", urlHash[:6])), true + return opts.download(ctx, custom.URL, dir.Join(fmt.Sprintf("api-docs-custom-%s.json", urlHash(custom.URL))), true) + } + if opts.Endpoint.URL != nil && !opts.Dev { + served := opts.Endpoint.JoinPath("api", "openapi") + spec, wait, err = opts.download(ctx, served, dir.Join(fmt.Sprintf("api-docs-%s.json", urlHash(served))), true) + if httpErr, ok := errors.AsType[http.Error](err); !ok || (!httpErr.IsUnauthorized() && !httpErr.IsNotFound()) { + return spec, wait, err + } + slog.DebugContext(ctx, fmt.Sprintf("%s serves no API docs at /api/openapi, reading those of the docs site", opts.Endpoint)) + } + + url, path, conditionalGet := releasedUrl, dir.Join("api-docs.json"), false + if cliVersion, parseErr := version.Parse(opts.Version); opts.Dev || parseErr != nil || !cliVersion.IsRelease() { + url, path, conditionalGet = devUrl, dir.Join("api-docs-dev.json"), true } + return opts.download(ctx, url.URL, path, conditionalGet) +} + +// urlHash names one file per URL, because the conditional GET asks only for a document newer than the +// stored one: after a switch to the docs of an older meshStack, it would keep those of the newer one. +func urlHash(url *gourl.URL) string { + sum := sha256.Sum256([]byte(url.String())) + return hex.EncodeToString(sum[:6]) +} - wait, err = opts.Client.Download(ctx, url.URL, path, http.WithConditionalGet(conditionalGet)) +func (opts Options) download(ctx context.Context, url *gourl.URL, path string, conditionalGet bool) (spec openapi.Spec, wait func(), err error) { + wait, err = opts.Client.Download(ctx, url, path, http.WithConditionalGet(conditionalGet)) if err != nil { return spec, wait, fmt.Errorf("cannot download the API docs from %s: %w", url, err) } diff --git a/internal/apidocs/apidocs_test.go b/internal/apidocs/apidocs_test.go new file mode 100644 index 00000000..f10cbe6f --- /dev/null +++ b/internal/apidocs/apidocs_test.go @@ -0,0 +1,84 @@ +package apidocs + +import ( + gohttp "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" +) + +func TestLoad(t *testing.T) { + apiDocs, err := os.ReadFile("../../client/openapi/testdata/spec.json") + require.NoError(t, err) + meshStack := fakemeshstack.Start(t, fakemeshstack.Options{ApiDocs: apiDocs}) + docsSite := fakemeshstack.Start(t, fakemeshstack.Options{ApiDocs: apiDocs}) + docsOfDevelop := devUrl + devUrl = xurl.MustParsef("%s%s", docsSite.URL, fakemeshstack.ApiDocsPath) + t.Cleanup(func() { devUrl = docsOfDevelop }) + t.Setenv(urlEnv, "") + + load := func(t *testing.T, dev bool) error { + t.Helper() + t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) + _, wait, err := Load(t.Context(), Options{ + Client: http.NewClient(fakemeshstack.UserAgent), + Endpoint: xurl.MustParsef("%s", meshStack.URL), + Dev: dev, + }) + wait() + return err + } + answer := func(status int) { + meshStack.Route("GET "+fakemeshstack.MeshStackApiDocsPath, func(w gohttp.ResponseWriter, _ *gohttp.Request) { + w.WriteHeader(status) + }) + } + paths := func(s *fakemeshstack.Server) (paths []string) { + for _, request := range s.TakeRequests() { + paths = append(paths, request.URL.Path) + } + return paths + } + + t.Run("reads the API docs of the meshStack it acts against", func(t *testing.T) { + require.NoError(t, load(t, false)) + + assert.Equal(t, []string{fakemeshstack.MeshStackApiDocsPath}, paths(meshStack)) + assert.Empty(t, paths(docsSite)) + }) + + t.Run("reads those of the docs site where the meshStack is older than its API docs", func(t *testing.T) { + for _, status := range []int{gohttp.StatusUnauthorized, gohttp.StatusNotFound} { + answer(status) + + require.NoError(t, load(t, false)) + + assert.Equal(t, []string{fakemeshstack.MeshStackApiDocsPath}, paths(meshStack)) + assert.Equal(t, []string{fakemeshstack.ApiDocsPath}, paths(docsSite)) + } + }) + + t.Run("fails where the meshStack refuses its API docs otherwise", func(t *testing.T) { + answer(gohttp.StatusForbidden) + + require.ErrorContains(t, load(t, false), "403") + + assert.Empty(t, paths(docsSite)) + }) + + t.Run("reads those of the docs site with --dev, without asking the meshStack", func(t *testing.T) { + meshStack.TakeRequests() + + require.NoError(t, load(t, true)) + + assert.Empty(t, paths(meshStack)) + assert.Equal(t, []string{fakemeshstack.ApiDocsPath}, paths(docsSite)) + }) +} diff --git a/internal/testutil/fakemeshstack/fakemeshstack.go b/internal/testutil/fakemeshstack/fakemeshstack.go index 11b60027..80f524f3 100644 --- a/internal/testutil/fakemeshstack/fakemeshstack.go +++ b/internal/testutil/fakemeshstack/fakemeshstack.go @@ -38,8 +38,9 @@ const Token = "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9." //nolint:gosec // var UserAgent = http.UserAgent{GitHubRepo: "meshcloud/fakemeshstack", Version: "test"} const ( - ApiDocsPath = "/api/meshstack-openapi-docs.json" - defaultPageSize = 50 + ApiDocsPath = "/api/meshstack-openapi-docs.json" + MeshStackApiDocsPath = "/api/openapi" + defaultPageSize = 50 ) type Options struct { @@ -62,8 +63,8 @@ type Options struct { PageSize int TfStates map[TfState][]byte - // ApiDocs is served at ApiDocsPath as last modified when New was called, so a conditional GET - // gets a 304. + // ApiDocs is served at ApiDocsPath and MeshStackApiDocsPath as last modified when New was called, + // so a conditional GET gets a 304. ApiDocs []byte } @@ -144,10 +145,12 @@ func New(options Options) *Server { s.routes.HandleFunc("/api/terraform/state/workspace/{workspace}/buildingBlock/{uuid}", s.authorized(s.tfState)) if options.ApiDocs != nil { modified := time.Now() - s.routes.HandleFunc("GET "+ApiDocsPath, func(w gohttp.ResponseWriter, r *gohttp.Request) { - w.Header().Set("Content-Type", "application/json") - gohttp.ServeContent(w, r, "", modified, bytes.NewReader(options.ApiDocs)) - }) + for _, path := range []string{ApiDocsPath, MeshStackApiDocsPath} { + s.routes.HandleFunc("GET "+path, func(w gohttp.ResponseWriter, r *gohttp.Request) { + w.Header().Set("Content-Type", "application/json") + gohttp.ServeContent(w, r, "", modified, bytes.NewReader(options.ApiDocs)) + }) + } } if options.Issuer != "" { s.registerKeycloak()