Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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. "+
Expand Down
16 changes: 11 additions & 5 deletions cmd/api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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/<uuid>
Expand All @@ -65,23 +69,25 @@ 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())
}
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
Expand Down
16 changes: 16 additions & 0 deletions cmd/api/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 6 additions & 5 deletions cmd/api/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
40 changes: 30 additions & 10 deletions internal/apidocs/apidocs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
gourl "net/url"
"os"
"strings"

Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
84 changes: 84 additions & 0 deletions internal/apidocs/apidocs_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
19 changes: 11 additions & 8 deletions internal/testutil/fakemeshstack/fakemeshstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
Loading