Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,23 @@ updates:
github-actions:
patterns:
- "*"

# Go module dependencies.
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
groups:
gomod:
patterns:
- "*"

# Frontend (npm) dependencies.
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
groups:
npm:
patterns:
- "*"
12 changes: 8 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ on:
push:
branches:
- main
# Release flavor is derived from the tag suffix in the deploy job:
# Release flavor is derived from the tag suffix in the deploy job. Convention: prefix tags
# with "v" (bare X.Y.Z is still accepted for backward compatibility).
tags:
- '*.*.*' # stable semver (e.g. 1.2.3): published and marked "Latest"
- '*-preview' # pre-release (e.g. 1.2.3-preview): published, never "Latest"
- '*-draft' # draft (e.g. 1.2.3-draft): unpublished for review, never "Latest"
- '*.*.*' # stable semver (e.g. v1.2.3): published and marked "Latest"
- '*-preview' # pre-release (e.g. v1.2.3-preview): published, never "Latest"
- '*-draft' # draft (e.g. v1.2.3-draft): unpublished for review, never "Latest"
pull_request:
branches:
- main
Expand Down Expand Up @@ -108,6 +109,9 @@ jobs:
make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} build-docker
make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} start-docker

- name: Smoke-test the docker image
run: make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} smoke-docker

- if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
node_modules/
# All build, test and runtime output goes here (see Makefile / vite.config.ts / playwright.config.ts).
build/
# The embedded client is copied here by the release build; keep only the placeholder in git.
server/frontend/dist/*
!server/frontend/dist/.gitkeep
21 changes: 14 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
ARG GO_VERSION=1.26
FROM golang:${GO_VERSION}-alpine AS build-backend
RUN apk add --no-cache make
RUN apk add --no-cache make ca-certificates
ARG VERSION=snapshot
ARG COMMIT
WORKDIR /go/src/smocker
COPY go.mod go.sum ./
RUN go mod download
COPY Makefile main.go ./
COPY server/ ./server/
RUN make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build
# The client is built on the host (make release); bake it into the binary via go:embed.
COPY build/client ./server/frontend/dist/
# CGO_ENABLED=0 produces a fully static binary (pure-Go net/tls), required for a scratch image.
RUN CGO_ENABLED=0 make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build

FROM alpine
FROM scratch
LABEL org.opencontainers.image.source="https://github.com/smocker-dev/smocker"
WORKDIR /opt
EXPOSE 8080 8081
COPY build/client client/
COPY --from=build-backend /go/src/smocker/build/* /opt/
CMD ["/opt/smocker"]
# CA roots so the proxy mock type can reach HTTPS backends (scratch ships none).
COPY --from=build-backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# The UI is embedded in the binary, so nothing else is needed — just the static binary.
COPY --from=build-backend /go/src/smocker/build/smocker /smocker
# Run unprivileged (nobody). The listen ports are >1024 so no privilege is needed; a mounted
# persistence directory must be writable by this uid.
USER 65534:65534
ENTRYPOINT ["/smocker"]
28 changes: 24 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ DOCKER_IMAGE=ghcr.io/smocker-dev/smocker
# A tag name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes.
# A tag name may not start with a period or a dash and may contain a maximum of 128 characters.
DOCKER_TAG:=$(shell echo $(VERSION) | tr -cd '[:alnum:]_.-')
IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$")
IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^v?[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$")

LEVEL=debug

Expand Down Expand Up @@ -155,10 +155,26 @@ build-docker:
start-docker: check-default-ports
docker run -d -p 8080:8080 -p 8081:8081 --name $(APPNAME) $(DOCKER_IMAGE):$(DOCKER_TAG)

# Smoke-test the running container (started by start-docker): the config API answers, the UI is
# served (proves the embedded client works — the image ships no client directory), and a mock
# registered on the config port is served on the mock port.
.PHONY: smoke-docker
smoke-docker:
@for i in $$(seq 1 30); do curl -sf http://localhost:8081/version >/dev/null 2>&1 && break; sleep 1; done
@curl -sf http://localhost:8081/version >/dev/null || { echo "FAIL: /version"; exit 1; }
@curl -sf http://localhost:8081/ | grep -q Smocker || { echo "FAIL: embedded UI not served"; exit 1; }
@curl -sf -XPOST http://localhost:8081/mocks -H "Content-Type: application/json" \
--data '[{"request":{"method":"GET","path":"/smoke"},"response":{"status":200,"body":"ok"}}]' >/dev/null \
|| { echo "FAIL: register mock"; exit 1; }
@test "$$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/smoke)" = "200" \
|| { echo "FAIL: mock round-trip"; exit 1; }
@echo "docker smoke tests passed"

.PHONY: check-default-ports
check-default-ports:
@lsof -i:8080 > /dev/null && (echo "Port 8080 already in use"; exit 1) || true
@lsof -i:8081 > /dev/null && (echo "Port 8081 already in use"; exit 1) || true
@lsof -i:$(PROXY_TARGET_PORT) > /dev/null && (echo "Port $(PROXY_TARGET_PORT) (go-httpbin) already in use"; exit 1) || true

.PHONY: optimize
optimize:
Expand All @@ -168,11 +184,15 @@ optimize:
# The following targets are only available for CI usage

build/smocker.tar.gz:
$(MAKE) build
# Build the client first, copy it into the embed source, then build the Go binary so the UI
# is baked in (go:embed). The result is a self-contained binary — no client dir to ship.
npm ci --ignore-scripts
npm run build
# Package only the release artifacts, not test/runtime output that may also live in build/.
cd build/; tar -czvf smocker.tar.gz smocker client
rm -rf server/frontend/dist && mkdir -p server/frontend/dist
cp -r build/client/. server/frontend/dist/
touch server/frontend/dist/.gitkeep
$(MAKE) build
cd build/; tar -czvf smocker.tar.gz smocker

.PHONY: release
release: build/smocker.tar.gz
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,15 @@ The backend is written in Go. You can use the following commands to manage the d

### Frontend

The frontend is written with TypeScript and React. You can use the following commands to manage the development lifecycle:

- `yarn install`: install the dependencies
- `yarn start`: start the frontend in development mode, with live reload
- `yarn build`: generate the transpiled and minified files and assets
- `yarn lint`: run static analysis on the code
- `yarn format`: automatically format the frontend code
- `yarn test`: execute unit tests
- `yarn test:watch`: execute unit tests, with live reload
The frontend is written with TypeScript and React, bundled with Vite. You can use the following commands to manage the development lifecycle:

- `npm install`: install the dependencies
- `npm run dev`: start the Vite dev server with hot reload. It proxies the admin API to the backend, so run `make start` alongside it (override the target with `SMOCKER_DEV_PROXY` if the backend is elsewhere)
- `npm run build`: generate the transpiled and minified files and assets
- `npm run lint`: run static analysis on the code
- `npm run format`: automatically format the frontend code
- `npm test`: execute unit tests
- `npm run test:watch`: execute unit tests, with live reload

### Docker

Expand Down
3 changes: 1 addition & 2 deletions client/modules/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,7 @@ describe("Generate curl command from:", () => {
},
};
expect(entryToCurl(entry)).toBe(
// FIXME: we shouldn't have the " if the client sent raw text
`curl -XPOST '/test' --data '"value containing \\'single quotes\\'"'`,
`curl -XPOST '/test' --data 'value containing \\'single quotes\\''`,
);
});
});
Expand Down
8 changes: 7 additions & 1 deletion client/modules/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ export const entryToCurl = (historyEntry: Entry): string => {
);

if (request.body) {
command.push(`--data '${escapeQuote(JSON.stringify(request.body))}'`);
// A raw/urlencoded body is already a string; only JSON bodies need serializing. Stringifying
// a string would wrap it in extra quotes (--data '"a=b"').
const data =
typeof request.body === "string"
? request.body
: JSON.stringify(request.body);
command.push(`--data '${escapeQuote(data)}'`);
}

return command.join(" ");
Expand Down
34 changes: 29 additions & 5 deletions server/admin_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/smocker-dev/smocker/server/config"
"github.com/smocker-dev/smocker/server/frontend"
"github.com/smocker-dev/smocker/server/handlers"
"github.com/smocker-dev/smocker/server/services"
"golang.org/x/sync/errgroup"
Expand Down Expand Up @@ -79,8 +80,14 @@ func Serve(config config.Config) {
return c.JSON(http.StatusOK, config.Build)
})

// UI Routes
adminServerEngine.Static("/assets", config.StaticFiles)
// UI Routes: serve from --static-files on disk when it holds a built index.html (development
// or an explicit override); otherwise fall back to the client embedded in the binary, so a
// released binary is self-contained.
if fileExists(config.StaticFiles + "/index.html") {
adminServerEngine.Static("/assets", config.StaticFiles)
} else if embedded, ok := frontend.FS(); ok {
adminServerEngine.StaticFS("/assets", embedded)
}
adminServerEngine.GET("/*", renderIndex(adminServerEngine, config))

slog.Info("Starting admin server", "port", config.ConfigListenPort)
Expand Down Expand Up @@ -153,15 +160,32 @@ func serve(tlsEnable bool, servers ...*http.Server) error {
return g.Wait()
}

// fileExists reports whether path exists (used to prefer an on-disk UI over the embedded one).
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

func renderIndex(e *echo.Echo, cfg config.Config) echo.HandlerFunc {
return func(c echo.Context) error {
// In development mode, index.html might not be available yet
// Parse index.html once: from --static-files on disk if present (dev/override), otherwise
// from the client embedded in the binary. In development the client may not be built yet.
if templateRenderer == nil {
template, err := template.ParseFiles(cfg.StaticFiles + "/index.html")
var (
tmpl *template.Template
err error
)
if diskIndex := cfg.StaticFiles + "/index.html"; fileExists(diskIndex) {
tmpl, err = template.ParseFiles(diskIndex)
} else if embedded, ok := frontend.FS(); ok {
tmpl, err = template.ParseFS(embedded, "index.html")
} else {
return c.String(http.StatusNotFound, "index is building...")
}
if err != nil {
return c.String(http.StatusNotFound, "index is building...")
}
templateRenderer := &TemplateRenderer{template}
templateRenderer = &TemplateRenderer{tmpl}
e.Renderer = templateRenderer
}

Expand Down
Empty file added server/frontend/dist/.gitkeep
Empty file.
27 changes: 27 additions & 0 deletions server/frontend/frontend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Package frontend embeds the built web UI so a released binary is self-contained (no need to
// ship the client directory alongside it). The dist directory is populated by the client build
// (see the Makefile release target); a committed placeholder keeps `go build` working before the
// client has ever been built.
package frontend

import (
"embed"
"io/fs"
)

//go:embed all:dist
var dist embed.FS

// FS returns the embedded client filesystem (rooted at dist) together with whether it actually
// holds a built UI (an index.html). When only the placeholder is present it returns ok=false, so
// callers fall back to serving from the --static-files directory on disk.
func FS() (fs.FS, bool) {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil, false
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return nil, false
}
return sub, true
}
9 changes: 5 additions & 4 deletions server/handlers/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,12 @@ func (m *Mocks) GenericHandler(c echo.Context) error {

/* Response writing */

// Headers
// Headers: assign to the header map directly instead of Add()/Set(), which canonicalize the
// key (e.g. "BrokerProperties" -> "Brokerproperties"). Smocker preserves the exact casing the
// mock declares, matching servers that treat header names case-sensitively.
header := c.Response().Header()
for key, values := range response.Headers {
for _, value := range values {
c.Response().Header().Add(key, value)
}
header[key] = []string(values)
}

// Delay
Expand Down
45 changes: 38 additions & 7 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function assetsPrefix(): Plugin {
let indexPath = "";
return {
name: "smocker-assets-prefix",
apply: "build",
configResolved(cfg) {
indexPath = resolve(cfg.root, cfg.build.outDir, "index.html");
},
Expand All @@ -30,13 +31,43 @@ function assetsPrefix(): Plugin {
};
}

export default defineConfig({
// In the dev server, Vite serves index.html without the Go template step, so the runtime globals
// stay as literal "{{.basePath}}" / "{{.version}}" and break the API base + <base href>. Substitute
// them for dev only; the production build keeps the placeholders for the Go server to render.
function devIndexHtml(): Plugin {
return {
name: "smocker-dev-index-html",
apply: "serve",
transformIndexHtml(html) {
return html
.replace(/\{\{\.basePath\}\}/g, "/")
.replace(/\{\{\.version\}\}/g, "dev");
},
};
}

// Where `npm run dev` proxies the API to (the running `make start` admin server).
const devProxyTarget = process.env.SMOCKER_DEV_PROXY ?? "http://localhost:8081";
const apiProxy = Object.fromEntries(
["/mocks", "/sessions", "/history", "/version", "/reset"].map((path) => [
path,
devProxyTarget,
]),
);

export default defineConfig(({ command }) => ({
root: "client",
base: "./",
plugins: [react(), assetsPrefix()],
// Allow importing the canonical mock schema (docs/mock.schema.json) from client code, which
// lives above the Vite root.
server: { fs: { allow: [import.meta.dirname] } },
// Dev serves from "/" (clean absolute URLs against the dev server); the build keeps "./" so the
// Go server's <base href> can relocate assets under any deployment base path.
base: command === "build" ? "./" : "/",
plugins: [react(), assetsPrefix(), devIndexHtml()],
server: {
// Allow importing the canonical mock schema (docs/mock.schema.json) from client code, which
// lives above the Vite root.
fs: { allow: [import.meta.dirname] },
// Proxy the admin API to the backend so `npm run dev` works against `make start`.
proxy: apiProxy,
},
build: {
outDir: "../build/client",
emptyOutDir: true,
Expand All @@ -48,4 +79,4 @@ export default defineConfig({
// Relative to the Vite root ("client"), so this matches client/**/*.test.{ts,tsx}.
include: ["**/*.test.{ts,tsx}"],
},
});
}));
Loading