Skip to content

Commit

Permalink
CLI: Mocking - allow local file external references (#774)
Browse files Browse the repository at this point in the history
* update openkin openapi library to latest version

* allow for setting up file watcher with multiple files at once

* add function to write api to temporary file that we will serve to openapi-mock container with all resolved refs internalised

* save progress

* remove unused set api function and printing of raw log

* allow local references

* passing correct path to parser

Signed-off-by: jasmingacic <jasmin.gacic@gmail.com>

* Added reportError where it was missing

Signed-off-by: jasmingacic <jasmin.gacic@gmail.com>

Signed-off-by: jasmingacic <jasmin.gacic@gmail.com>
Co-authored-by: jasmingacic <jasmin.gacic@gmail.com>
  • Loading branch information
Kyle Hodgetts and jasmingacic committed Sep 29, 2022
1 parent 3051847 commit d4992d7
Show file tree
Hide file tree
Showing 6 changed files with 433 additions and 320 deletions.
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)
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)
}
if err := os.Remove(fileName); err != nil {
reportError(err)
ui.Fail(err)
}
}(tempApiFileName)

if err := writeInitialisedApiToTempFile(tempApiFileName, apiSpec); err != nil {
reportError(err)
ui.Fail(err)
}

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

0 comments on commit d4992d7

Please sign in to comment.