Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI: Mocking - allow local file external references #774

Merged
492 changes: 231 additions & 261 deletions cmd/kusk/cmd/manifest_data.go

Large diffs are not rendered by default.

211 changes: 176 additions & 35 deletions cmd/kusk/cmd/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
Expand Down Expand Up @@ -135,76 +136,112 @@ $ kusk mock -i https://url.to.api.com
ui.Fail(err)
}

mockingConfigFilePath := path.Join(homeDir, ".kusk", "openapi-mock.yaml")
if err := writeMockingConfigIfNotExists(mockingConfigFilePath); err != nil {
reportError(err)
ui.Fail(err)
}
kuskConfigDir := path.Join(homeDir, ".kusk")

spec, err := spec.NewParser(&openapi3.Loader{IsExternalRefsAllowed: true}).Parse(apiSpecPath)
u, err := url.Parse(apiSpecPath)
if err != nil {
err := fmt.Errorf("error when parsing openapi spec: %w", err)
reportError(err)
ui.Fail(err)
}

if err := spec.Validate(context.Background()); err != nil {
err := fmt.Errorf("openapi spec failed validation: %w", err)
reportError(err)
ui.Fail(err)
}

ui.Info(ui.Green("🎉 successfully parsed OpenAPI spec"))
apiOnFileSystem := u.Host == ""

u, err := url.Parse(apiSpecPath)
if err != nil {
reportError(err)
ui.Fail(err)
}
// the api spec location could be a url or a file
// if it's a file, this will get updated below to be the absolute path to the file
apiSpecLocation := apiSpecPath

// if api on the file system, change into the directory
// where it's kept as it could have local schema references that need
// to be resolved.
// also set up a file watcher
var watcher *filewatcher.FileWatcher
absoluteApiSpecPath := apiSpecPath
if apiOnFileSystem := u.Host == ""; apiOnFileSystem {
if apiOnFileSystem {
// we need the absolute path of the file in the filesystem
// to properly mount the file into the mocking container
absoluteApiSpecPath, err = filepath.Abs(apiSpecPath)
absoluteApiSpecPath, err := filepath.Abs(apiSpecPath)
if err != nil {
reportError(err)
ui.Fail(err)
}

popFunc, err := pushDirectory(filepath.Dir(absoluteApiSpecPath))
defer func() {
if err := popFunc(); err != nil {
reportError(err)
ui.Fail(err)
}
}()

watcher, err = filewatcher.New(absoluteApiSpecPath)
if err != nil {
reportError(err)
ui.Fail(err)
}
defer watcher.Close()

apiSpecLocation = absoluteApiSpecPath
}

ui.Info(ui.White("☀️ initializing mocking server"))
apiParser := spec.NewParser(&openapi3.Loader{
IsExternalRefsAllowed: true,
ReadFromURIFunc: openapi3.ReadFromURIs(openapi3.ReadFromHTTP(http.DefaultClient), openapi3.ReadFromFile),
})

cli, err := client.NewClientWithOpts(client.FromEnv)
apiSpec, err := apiParser.Parse(apiSpecLocation)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kylehodgetts i took the liberty to fix this.

if err != nil {
err := fmt.Errorf("unable to create new docker client from environment: %w", err)
err := fmt.Errorf("error when parsing openapi spec: %w", err)
reportError(err)
ui.Fail(err)
}

if mockServerPort == 0 {
mockServerPort, err = scanForNextAvailablePort(8080)
if err := apiSpec.Validate(context.Background()); err != nil {
err := fmt.Errorf("openapi spec failed validation: %w", err)
reportError(err)
ui.Fail(err)
}

ui.Info(ui.Green("🎉 successfully parsed OpenAPI spec"))

var tempApiFileName string
apiToMock := apiSpecLocation
if apiOnFileSystem {

tempApiFile, err := os.CreateTemp(kuskConfigDir, "mocked-api-*.yaml")
if err != nil {
reportError(err)
ui.Fail(err)
}

tempApiFileName = tempApiFile.Name()

defer func(fileName string) {
if err := tempApiFile.Close(); err != nil {
reportError(err)
ui.Fail(err)
mbana marked this conversation as resolved.
Show resolved Hide resolved
}
if err := os.Remove(fileName); err != nil {
reportError(err)
ui.Fail(err)
mbana marked this conversation as resolved.
Show resolved Hide resolved
}
}(tempApiFileName)

if err := writeInitialisedApiToTempFile(tempApiFileName, apiSpec); err != nil {
reportError(err)
ui.Fail(err)
jasmingacic marked this conversation as resolved.
Show resolved Hide resolved
}

apiToMock = tempApiFileName
}

ctx := context.Background()
mockServer, err := mockingServer.New(ctx, cli, mockingConfigFilePath, absoluteApiSpecPath, mockServerPort)
ui.Info(ui.White("☀️ initializing mocking server"))
mockServer, err := setUpMockingServer(kuskConfigDir, apiToMock)
if err != nil {
reportError(err)
ui.Fail(err)
msg := fmt.Errorf("error when setting up mocking server: %w", err)
reportError(msg)
ui.Fail(msg)
}

ctx := context.Background()
mockServerId, err := mockServer.Start(ctx)
if err != nil {
reportError(err)
Expand All @@ -228,8 +265,8 @@ $ kusk mock -i https://url.to.api.com
ui.Info(ui.White("⏳ watching for file changes in " + apiSpecPath))
go watcher.Watch(func() {
ui.Info("✍️ change detected in " + apiSpecPath)
if err := mockServer.Stop(ctx, mockServerId); err != nil {
err := fmt.Errorf("unable to update mocking server")
err := apiFileUpdateHandler(ctx, mockServer, apiSpecLocation, tempApiFileName, mockServerId)
if err != nil {
reportError(err)
ui.Fail(err)
}
Expand All @@ -245,7 +282,7 @@ $ kusk mock -i https://url.to.api.com
if status.Error == nil && status.StatusCode > 0 {
mockServerId, err = mockServer.Start(ctx)
if err != nil {
err := fmt.Errorf("unable to update mocking server")
err := fmt.Errorf("unable to restart mocking server")
reportError(err)
ui.Fail(err)
}
Expand Down Expand Up @@ -288,6 +325,110 @@ $ kusk mock -i https://url.to.api.com
},
}

func pushDirectory(dir string) (popFunc func() error, err error) {
noPopFunc := func() error { return nil }

var currentWorkingDir string

if dir == "" {
return noPopFunc, nil
}

if currentWorkingDir, err = os.Getwd(); err != nil {
return noPopFunc, err
}

if err := os.Chdir(dir); err != nil {
return noPopFunc, err
}

return func() error {
if currentWorkingDir == "" {
return nil
}

if err := os.Chdir(currentWorkingDir); err != nil {
return fmt.Errorf("unable to change back to directory %s: %w", currentWorkingDir, err)
}

return nil
}, nil
}

func setUpMockingServer(kuskConfigDir, apiToMock string) (*mockingServer.MockServer, error) {
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return nil, fmt.Errorf("unable to create new docker client from environment: %w", err)
}

if mockServerPort == 0 {
mockServerPort, err = scanForNextAvailablePort(8080)
if err != nil {
return nil, fmt.Errorf("unable to find available port for mocking server: %w", err)
}
}

mockingConfigFilePath := path.Join(kuskConfigDir, "openapi-mock.yaml")
if err := writeMockingConfigIfNotExists(mockingConfigFilePath); err != nil {
return nil, fmt.Errorf("unable to write mocking config file: %w", err)
}

return mockingServer.New(cli, mockingConfigFilePath, apiToMock, mockServerPort), nil
}

func writeInitialisedApiToTempFile(fileName string, api *openapi3.T) error {
api.InternalizeRefs(context.Background(), nil)
apiBytes, err := api.MarshalJSON()
if err != nil {
return err
}

file, err := os.OpenFile(fileName, os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return err
}

if err := file.Truncate(0); err != nil {
return err
}

if _, err := file.Seek(0, io.SeekStart); err != nil {
return err
}

if _, err = file.Write(apiBytes); err != nil {
return err
}

if err := file.Close(); err != nil {
return err
}

return nil
}

func apiFileUpdateHandler(
ctx context.Context,
mockServer *mockingServer.MockServer,
apiFileName, tempApiFileName, mockServerId string,
) error {
apiSpec, err := spec.NewParser(&openapi3.Loader{
IsExternalRefsAllowed: true,
ReadFromURIFunc: openapi3.ReadFromFile,
}).Parse(apiFileName)
if err != nil {
return fmt.Errorf("unable to parse api spec: %w", err)
}
if err := writeInitialisedApiToTempFile(tempApiFileName, apiSpec); err != nil {
return fmt.Errorf("unable to write api spec to temp file: %w", err)
}
if err := mockServer.Stop(ctx, mockServerId); err != nil {
return fmt.Errorf("unable to update mocking server: %w", err)
}

return nil
}

func scanForNextAvailablePort(startingPort uint32) (uint32, error) {
localPortCheck := func(port uint32) error {
ln, err := net.Listen("tcp", "127.0.0.1:"+fmt.Sprint(port))
Expand All @@ -307,7 +448,7 @@ func scanForNextAvailablePort(startingPort uint32) (uint32, error) {
}
}

return 0, errors.New("no available local port")
return 0, fmt.Errorf("unable to find available port between %d-%d", startingPort, maxPortNumber)
}

func writeMockingConfigIfNotExists(mockingConfigPath string) error {
Expand Down
8 changes: 5 additions & 3 deletions cmd/kusk/internal/mocking/filewatcher/filewatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ type FileWatcher struct {
watcher *fsnotify.Watcher
}

func New(filePath string) (*FileWatcher, error) {
func New(filePaths ...string) (*FileWatcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("unable to create new file watcher: %w", err)
}

if err := watcher.Add(filePath); err != nil {
return nil, fmt.Errorf("unable to add file %s to watcher: %w", filePath, err)
for _, filePath := range filePaths {
if err := watcher.Add(filePath); err != nil {
return nil, fmt.Errorf("unable to watch file %s: %w", filePath, err)
}
}

return &FileWatcher{
Expand Down
38 changes: 18 additions & 20 deletions cmd/kusk/internal/mocking/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,30 +38,28 @@ type AccessLogEntry struct {
Error error
}

func New(ctx context.Context, client *client.Client, configFile, apiToMock string, port uint32) (MockServer, error) {
const openApiMockImage = "muonsoft/openapi-mock:v0.3.3"

reader, err := client.ImagePull(ctx, openApiMockImage, types.ImagePullOptions{})
if err != nil {
return MockServer{}, fmt.Errorf("unable to pull mock server image: %w", err)
}

// wait for download to complete, discard output
defer reader.Close()
io.Copy(io.Discard, reader)

return MockServer{
func New(client *client.Client, configFile, apiToMock string, port uint32) *MockServer {
return &MockServer{
client: client,
image: openApiMockImage,
image: "muonsoft/openapi-mock:v0.3.3",
configFile: configFile,
apiToMock: apiToMock,
port: port,
LogCh: make(chan AccessLogEntry),
ErrCh: make(chan error),
}, nil
}
}

func (m MockServer) Start(ctx context.Context) (string, error) {
func (m *MockServer) Start(ctx context.Context) (string, error) {
reader, err := m.client.ImagePull(ctx, m.image, types.ImagePullOptions{})
if err != nil {
return "", fmt.Errorf("unable to pull mock server image: %w", err)
}

// wait for download to complete, discard output
defer reader.Close()
io.Copy(io.Discard, reader)

u, err := url.Parse(m.apiToMock)
if err != nil {
return "", err
Expand Down Expand Up @@ -124,21 +122,21 @@ func (m MockServer) Start(ctx context.Context) (string, error) {
return resp.ID, nil
}

func (m MockServer) Restart(ctx context.Context, MockServerId string) error {
func (m *MockServer) Restart(ctx context.Context, MockServerId string) error {
timeout := 5 * time.Second
return m.client.ContainerRestart(ctx, MockServerId, &timeout)
}

func (m MockServer) Stop(ctx context.Context, MockServerId string) error {
func (m *MockServer) Stop(ctx context.Context, MockServerId string) error {
timeout := 5 * time.Second
return m.client.ContainerStop(ctx, MockServerId, &timeout)
}

func (m MockServer) ServerWait(ctx context.Context, MockServerId string) (<-chan container.ContainerWaitOKBody, <-chan error) {
func (m *MockServer) ServerWait(ctx context.Context, MockServerId string) (<-chan container.ContainerWaitOKBody, <-chan error) {
return m.client.ContainerWait(ctx, MockServerId, container.WaitConditionNextExit)
}

func (m MockServer) StreamLogs(ctx context.Context, containerId string) {
func (m *MockServer) StreamLogs(ctx context.Context, containerId string) {
reader, err := m.client.ContainerLogs(ctx, containerId, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/avast/retry-go/v3 v3.1.1
github.com/denisbrodbeck/machineid v1.0.1
github.com/envoyproxy/go-control-plane v0.10.3
github.com/getkin/kin-openapi v0.97.0
github.com/getkin/kin-openapi v0.103.0
github.com/ghodss/yaml v1.0.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/gofrs/uuid v4.0.0+incompatible
Expand Down