From 9bd3e93d038f43dd7d929216ff2dc9feddf7a67f Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Wed, 28 Oct 2026 15:57:43 +0100 Subject: [PATCH] feat(api): read the API docs that the meshStack itself serves meshstack api and meshstack api-docs now read the API docs from /api/openapi of the meshStack they act against, so the docs list the versions that meshStack offers rather than those of the latest release. api-docs takes the endpoint from the flag, the environment or the current stored profile, and reads no credential, so it still works without a login. A meshStack older than /api/openapi answers 401 or 404 there; the CLI then reads the docs of the docs site, as before. --dev and MESHSTACK_API_DOCS_URL still win over the endpoint. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- cmd/api/api.go | 2 +- cmd/api/docs.go | 16 ++-- cmd/api/profile.go | 16 ++++ cmd/api/request.go | 11 +-- internal/apidocs/apidocs.go | 40 ++++++--- internal/apidocs/apidocs_test.go | 84 +++++++++++++++++++ .../testutil/fakemeshstack/fakemeshstack.go | 19 +++-- 8 files changed, 161 insertions(+), 31 deletions(-) create mode 100644 internal/apidocs/apidocs_test.go diff --git a/README.md b/README.md index 261fde97..7992fea1 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,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 b9424618..c2abd726 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -76,7 +76,7 @@ Replace the same command line with 'api-docs' in place of 'api' to describe the 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 as given, without the API docs: %s. "+ diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 30a9670a..f9f81da3 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" ) @@ -47,7 +48,10 @@ version it offers. It takes no path, and no flag of the request. --output json writes the part of the OpenAPI document instead. ---dev uses the development API docs.`, +The API docs are those your meshStack serves, so they list what it offers. Without an endpoint, or +for a meshStack too old to serve them, they are those of the latest release on docs.meshcloud.io. + +--dev uses the API docs of what meshStack's develop branch has merged instead.`, Example: ` meshstack api-docs meshstack api-docs -X DELETE meshstack api-docs /api/meshobjects/meshtenants/ @@ -65,10 +69,10 @@ version it offers. It takes no path, and no flag of the request. if describe != "" && len(args) > 0 { return fmt.Errorf("--%s takes no 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 holds %s; give the path relative to the endpoint", r.target.Redacted()) @@ -76,12 +80,14 @@ version it offers. It takes no path, and no flag of the request. 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 49385466..ffde0d0e 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 for endpoint %s, not for 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 019be14c..e41cf652 100644 --- a/cmd/api/request.go +++ b/cmd/api/request.go @@ -126,16 +126,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()