diff --git a/.github/workflows/wiremock.yml b/.github/workflows/wiremock.yml new file mode 100644 index 0000000..5758fa3 --- /dev/null +++ b/.github/workflows/wiremock.yml @@ -0,0 +1,57 @@ +name: LocalStack WireMock Extension Tests + +on: + pull_request: + branches: + - main + paths: + - 'wiremock/**' + push: + branches: + - main + paths: + - 'wiremock/**' + workflow_dispatch: + +env: + LOCALSTACK_DISABLE_EVENTS: "1" + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + +jobs: + integration-tests: + name: Run WireMock Extension Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Set up LocalStack and extension + run: | + cd wiremock + + docker pull localstack/localstack-pro & + docker pull wiremock/wiremock & + docker pull public.ecr.aws/lambda/python:3.9 & + pip install localstack terraform-local awscli-local[ver1] + + make install + make dist + localstack extensions -v install file://$(ls ./dist/localstack_wiremock-*.tar.gz) + + DEBUG=1 localstack start -d + localstack wait + + - name: Run sample app test + run: | + cd wiremock + make sample + + - name: Print logs + if: always() + run: | + localstack logs + localstack stop diff --git a/wiremock/.gitignore b/wiremock/.gitignore new file mode 100644 index 0000000..4bb5981 --- /dev/null +++ b/wiremock/.gitignore @@ -0,0 +1,9 @@ +.venv +dist +build +**/*.egg-info +.eggs +.terraform* +terraform.tfstate* +*.zip +.wiremock diff --git a/wiremock/Makefile b/wiremock/Makefile new file mode 100644 index 0000000..28a654f --- /dev/null +++ b/wiremock/Makefile @@ -0,0 +1,54 @@ +VENV_BIN = python3 -m venv +VENV_DIR ?= .venv +VENV_ACTIVATE = $(VENV_DIR)/bin/activate +VENV_RUN = . $(VENV_ACTIVATE) + +usage: ## Shows usage for this Makefile + @cat Makefile | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +venv: $(VENV_ACTIVATE) + +$(VENV_ACTIVATE): pyproject.toml + test -d .venv || $(VENV_BIN) .venv + $(VENV_RUN); pip install --upgrade pip setuptools plux + $(VENV_RUN); pip install -e .[dev] + touch $(VENV_DIR)/bin/activate + +clean: + rm -rf .venv/ + rm -rf build/ + rm -rf .eggs/ + rm -rf *.egg-info/ + +install: venv ## Install dependencies + $(VENV_RUN); python -m plux entrypoints + +dist: venv ## Create distribution + $(VENV_RUN); python -m build + +publish: clean-dist venv dist ## Publish extension to pypi + $(VENV_RUN); pip install --upgrade twine; twine upload dist/* + +entrypoints: venv # Generate plugin entrypoints for Python package + $(VENV_RUN); python -m plux entrypoints + +format: ## Run ruff to format the whole codebase + $(VENV_RUN); python -m ruff format .; python -m ruff check --output-format=full --fix . + +test: ## Run integration tests (requires LocalStack running with the Extension installed) + $(VENV_RUN); pytest tests $(PYTEST_ARGS) + +sample: ## Deploy sample app + echo "Creating stubs in WireMock ..." + bin/create-stubs.sh + echo "Deploying sample app into LocalStack via Terraform ..." + (cd sample-app; tflocal init; tflocal apply -auto-approve) + apiId=$$(awslocal apigateway get-rest-apis | jq -r '.items[0].id'); \ + endpoint=https://$$apiId.execute-api.us-east-1.localhost.localstack.cloud/dev/time-off; \ + echo "Invoking local API Gateway endpoint: $$endpoint"; \ + curl -k -v $$endpoint | grep time_off_date + +clean-dist: clean + rm -rf dist/ + +.PHONY: clean clean-dist dist install publish usage venv format test diff --git a/wiremock/README.md b/wiremock/README.md new file mode 100644 index 0000000..69979a1 --- /dev/null +++ b/wiremock/README.md @@ -0,0 +1,117 @@ +WireMock on LocalStack +======================== + +This repo contains a [LocalStack Extension](https://github.com/localstack/localstack-extensions) that facilitates developing [WireMock](https://wiremock.org)-based applications locally. + +The extension supports two modes: +- **OSS WireMock**: Uses the open-source `wiremock/wiremock` image (default) +- **WireMock Runner**: Uses `wiremock/wiremock-runner` with WireMock Cloud integration (requires API token) + +## Prerequisites + +* Docker +* LocalStack Pro (free trial available) +* `localstack` CLI +* `make` +* [WireMock CLI](https://docs.wiremock.io/cli/overview) (for WireMock Runner mode) + +## Install from GitHub repository + +This extension can be installed directly from this Github repo via: + +```bash +localstack extensions install "git+https://github.com/localstack/localstack-extensions.git#egg=localstack-wiremock&subdirectory=wiremock" +``` + +## Install local development version + +To install the extension into localstack in developer mode, you will need Python 3.11, and create a virtual environment in the extensions project. + +In the newly generated project, simply run + +```bash +make install +``` + +Then, to enable the extension for LocalStack, run + +```bash +localstack extensions dev enable . +``` + +You can then start LocalStack with `EXTENSION_DEV_MODE=1` to load all enabled extensions: + +```bash +EXTENSION_DEV_MODE=1 localstack start +``` + +## Usage + +### OSS WireMock Mode (Default) + +Start LocalStack without any special configuration: + +```bash +localstack start +``` + +The WireMock server will be available at `http://wiremock.localhost.localstack.cloud:4566`. + +You can import stubs using the WireMock Admin API: + +```bash +curl -X POST -H "Content-Type: application/json" \ + --data-binary "@stubs.json" \ + "http://wiremock.localhost.localstack.cloud:4566/__admin/mappings/import" +``` + +### WireMock Runner Mode (Cloud Integration) + +To use WireMock Runner with WireMock Cloud, you need: +1. A WireMock Cloud API token +2. A `.wiremock` directory with your mock API configuration + +#### Step 1: Get your WireMock Cloud API Token + +1. Sign up at [WireMock Cloud](https://app.wiremock.cloud) +2. Go to Settings → API Tokens +3. Create a new token + +#### Step 2: Create your Mock API configuration + +First, create a Mock API in WireMock Cloud, then pull the configuration locally: + +```bash +# Install WireMock CLI if not already installed +npm install -g wiremock + +# Login with your API token +wiremock login + +# Pull your Mock API configuration +# Find your Mock API ID from the WireMock Cloud URL (e.g., https://app.wiremock.cloud/mock-apis/zwg1l/...) +wiremock pull mock-api +``` + +This creates a `.wiremock` directory with your `wiremock.yaml` configuration. + +#### Step 3: Start LocalStack with WireMock Runner + +```bash +LOCALSTACK_WIREMOCK_API_TOKEN="your-api-token" \ +LOCALSTACK_WIREMOCK_CONFIG_DIR="/path/to/your/project" \ +localstack start +``` + +**Environment Variables:** +- `WIREMOCK_API_TOKEN`: Your WireMock Cloud API token (required for runner mode) +- `WIREMOCK_CONFIG_DIR`: Path to the directory containing your `.wiremock` folder (required for runner mode) + +Note: When using the LocalStack CLI, prefix environment variables with `LOCALSTACK_` to forward them to the container. + +## Sample Application + +See the `sample-app/` directory for a complete example using Terraform that demonstrates: +- Creating an API Gateway +- Lambda function that calls WireMock stubs +- Integration testing with mocked external APIs diff --git a/wiremock/bin/create-stubs.sh b/wiremock/bin/create-stubs.sh new file mode 100755 index 0000000..9f85551 --- /dev/null +++ b/wiremock/bin/create-stubs.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Import stubs into OSS WireMock (for WireMock Runner, use setup-wiremock-runner.sh) + +STUBS_URL="${STUBS_URL:-https://library.wiremock.org/catalog/api/p/personio.de/personio-de-personnel/personio.de-personnel-stubs.json}" +TMP_STUBS_FILE="/tmp/personio-stubs.json" +WIREMOCK_URL="${WIREMOCK_URL:-http://wiremock.localhost.localstack.cloud:4566}" + +echo "Downloading stubs from ${STUBS_URL}..." +curl -s -o "$TMP_STUBS_FILE" "$STUBS_URL" + +echo "Importing stubs into WireMock at ${WIREMOCK_URL}..." +curl -v -X POST -H "Content-Type: application/json" --data-binary "@$TMP_STUBS_FILE" "${WIREMOCK_URL}/__admin/mappings/import" + +echo "" +echo "Verify stubs at: ${WIREMOCK_URL}/__admin/mappings" diff --git a/wiremock/bin/setup-wiremock-runner.sh b/wiremock/bin/setup-wiremock-runner.sh new file mode 100755 index 0000000..05baa37 --- /dev/null +++ b/wiremock/bin/setup-wiremock-runner.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Setup WireMock Runner for LocalStack + +set -e + +MOCK_API_NAME="${MOCK_API_NAME:-wiremock}" +MOCK_API_PORT="${MOCK_API_PORT:-8080}" +WIREMOCK_DIR="${WIREMOCK_DIR:-.wiremock}" +STUBS_URL="${STUBS_URL:-https://library.wiremock.org/catalog/api/p/personio.de/personio-de-personnel/personio.de-personnel-stubs.json}" + +echo "=== WireMock Runner Setup ===" + +# Check prerequisites +if ! command -v wiremock &> /dev/null; then + echo "Error: WireMock CLI not installed. Run: npm install -g wiremock" + exit 1 +fi + +if ! wiremock mock-apis list &> /dev/null; then + echo "Error: Not logged in. Run: wiremock login" + exit 1 +fi + +echo "✓ CLI authenticated" + +# Create Mock API +echo "Creating Mock API '${MOCK_API_NAME}'..." +wiremock mock-apis create "${MOCK_API_NAME}" 2>&1 || echo "Note: May already exist" + +wiremock mock-apis list +echo "" +echo "Enter Mock API ID:" +read -r MOCK_API_ID + +[ -z "$MOCK_API_ID" ] && { echo "Error: Mock API ID required"; exit 1; } + +# Create config +mkdir -p "${WIREMOCK_DIR}/stubs/${MOCK_API_NAME}/mappings" + +cat > "${WIREMOCK_DIR}/wiremock.yaml" << EOF +services: + ${MOCK_API_NAME}: + type: 'REST' + name: '${MOCK_API_NAME}' + port: ${MOCK_API_PORT} + path: '/' + cloud_id: '${MOCK_API_ID}' +EOF + +echo "✓ Created ${WIREMOCK_DIR}/wiremock.yaml" + +# Download stubs +TMP_STUBS_FILE="/tmp/wiremock-stubs.json" +curl -s -o "$TMP_STUBS_FILE" "$STUBS_URL" + +if [ -f "$TMP_STUBS_FILE" ] && command -v jq &> /dev/null; then + MAPPING_COUNT=$(jq '.mappings | length' "$TMP_STUBS_FILE" 2>/dev/null || jq 'length' "$TMP_STUBS_FILE" 2>/dev/null || echo "0") + if [ "$MAPPING_COUNT" != "0" ] && [ "$MAPPING_COUNT" != "null" ]; then + for i in $(seq 0 $((MAPPING_COUNT - 1))); do + jq ".mappings[$i] // .[$i]" "$TMP_STUBS_FILE" > "${WIREMOCK_DIR}/stubs/${MOCK_API_NAME}/mappings/mapping-$i.json" 2>/dev/null + done + echo "✓ Extracted ${MAPPING_COUNT} stubs" + fi +fi + +echo "" +echo "=== Setup Complete ===" +echo "" +echo "Start LocalStack with:" +echo " LOCALSTACK_WIREMOCK_API_TOKEN=\"your-token\" \\" +echo " LOCALSTACK_WIREMOCK_CONFIG_DIR=\"$(pwd)\" \\" +echo " localstack start" +echo "" +echo "WireMock available at: http://wiremock.localhost.localstack.cloud:4566" diff --git a/wiremock/localstack_wiremock/__init__.py b/wiremock/localstack_wiremock/__init__.py new file mode 100644 index 0000000..a802809 --- /dev/null +++ b/wiremock/localstack_wiremock/__init__.py @@ -0,0 +1 @@ +name = "localstack_wiremock" diff --git a/wiremock/localstack_wiremock/extension.py b/wiremock/localstack_wiremock/extension.py new file mode 100644 index 0000000..b58c9be --- /dev/null +++ b/wiremock/localstack_wiremock/extension.py @@ -0,0 +1,90 @@ +import logging +import os +from pathlib import Path + +from localstack import config, constants +from localstack_wiremock.utils.docker import ProxiedDockerContainerExtension + + +LOG = logging.getLogger(__name__) + +# If set, uses wiremock-runner image; otherwise uses OSS wiremock image +ENV_WIREMOCK_API_TOKEN = "WIREMOCK_API_TOKEN" +# Host path to directory containing .wiremock/ (required for runner mode) +ENV_WIREMOCK_CONFIG_DIR = "WIREMOCK_CONFIG_DIR" + +SERVICE_PORT = 8080 # Mock API port +ADMIN_PORT = 9999 # Admin interface port (runner mode) + + +class WireMockExtension(ProxiedDockerContainerExtension): + name = "localstack-wiremock" + + HOST = "wiremock." + DOCKER_IMAGE = "wiremock/wiremock" + DOCKER_IMAGE_RUNNER = "wiremock/wiremock-runner" + CONTAINER_NAME = "ls-wiremock" + + def __init__(self): + env_vars = {} + image_name = self.DOCKER_IMAGE + volumes = None + container_ports = [SERVICE_PORT] + health_check_path = "/__admin/health" + health_check_retries = 40 + health_check_sleep = 1 + + if api_token := os.getenv(ENV_WIREMOCK_API_TOKEN): + # WireMock Runner mode + env_vars["WMC_ADMIN_PORT"] = str(ADMIN_PORT) + env_vars["WMC_API_TOKEN"] = api_token + env_vars["WMC_RUNNER_ENABLED"] = "true" + image_name = self.DOCKER_IMAGE_RUNNER + container_ports = [SERVICE_PORT, ADMIN_PORT] + health_check_path = "/__/health" + health_check_retries = 90 + health_check_sleep = 2 + + host_config_dir = os.getenv(ENV_WIREMOCK_CONFIG_DIR) + + if not host_config_dir: + LOG.error("WIREMOCK_CONFIG_DIR is required for WireMock runner mode") + raise ValueError( + "WIREMOCK_CONFIG_DIR must be set to the host path containing .wiremock/" + ) + + host_wiremock_dir = os.path.join(host_config_dir, ".wiremock") + + # Validate config in dev mode + extension_dir = Path(__file__).parent.parent + container_wiremock_dir = extension_dir / ".wiremock" + container_wiremock_yaml = container_wiremock_dir / "wiremock.yaml" + + if container_wiremock_dir.is_dir() and container_wiremock_yaml.is_file(): + LOG.info("WireMock config found at: %s", container_wiremock_dir) + else: + LOG.warning("Ensure %s/.wiremock/wiremock.yaml exists", host_config_dir) + + LOG.info("Mounting WireMock config from: %s", host_wiremock_dir) + volumes = [(host_wiremock_dir, "/work/.wiremock")] + + health_check_port = ADMIN_PORT if api_token else SERVICE_PORT + self._is_runner_mode = bool(api_token) + + super().__init__( + image_name=image_name, + container_ports=container_ports, + container_name=self.CONTAINER_NAME, + host=self.HOST, + env_vars=env_vars if env_vars else None, + volumes=volumes, + health_check_path=health_check_path, + health_check_port=health_check_port, + health_check_retries=health_check_retries, + health_check_sleep=health_check_sleep, + ) + + def on_platform_ready(self): + url = f"http://wiremock.{constants.LOCALHOST_HOSTNAME}:{config.get_edge_port_http()}" + mode = "Runner" if self._is_runner_mode else "OSS" + LOG.info("WireMock %s extension ready: %s", mode, url) diff --git a/wiremock/localstack_wiremock/utils/__init__.py b/wiremock/localstack_wiremock/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiremock/localstack_wiremock/utils/docker.py b/wiremock/localstack_wiremock/utils/docker.py new file mode 100644 index 0000000..c102e83 --- /dev/null +++ b/wiremock/localstack_wiremock/utils/docker.py @@ -0,0 +1,220 @@ +import re +import logging +from functools import cache +from typing import Callable +import requests + +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.extensions.api import Extension, http +from localstack.http import Request +from localstack.utils.container_utils.container_client import ( + PortMappings, + SimpleVolumeBind, +) +from localstack.utils.net import get_addressable_container_host +from localstack.utils.sync import retry +from rolo import route +from rolo.proxy import Proxy +from rolo.routing import RuleAdapter, WithHost + +LOG = logging.getLogger(__name__) +logging.basicConfig() + +# TODO: merge utils with code in TypeDB extension over time ... + + +class ProxiedDockerContainerExtension(Extension): + name: str + """Name of this extension""" + image_name: str + """Docker image name""" + container_name: str | None + """Name of the Docker container spun up by the extension""" + container_ports: list[int] + """List of network ports of the Docker container spun up by the extension""" + host: str | None + """ + Optional host on which to expose the container endpoints. + Can be either a static hostname, or a pattern like `myext.` + """ + path: str | None + """Optional path on which to expose the container endpoints.""" + command: list[str] | None + """Optional command (and flags) to execute in the container.""" + + request_to_port_router: Callable[[Request], int] | None + """Callable that returns the target port for a given request, for routing purposes""" + http2_ports: list[int] | None + """List of ports for which HTTP2 proxy forwarding into the container should be enabled.""" + + volumes: list[SimpleVolumeBind] | None = None + """Optional volumes to mount into the container host.""" + + env_vars: dict[str, str] | None = None + """Optional environment variables to pass to the container.""" + + health_check_path: str = "/__admin/health" + """Health check endpoint path to verify container is ready.""" + + health_check_port: int | None = None + """Port to use for health check. If None, uses the first container port.""" + + health_check_retries: int = 40 + """Number of retries for health check.""" + + health_check_sleep: float = 1 + """Sleep time between health check retries in seconds.""" + + def __init__( + self, + image_name: str, + container_ports: list[int], + host: str | None = None, + path: str | None = None, + container_name: str | None = None, + command: list[str] | None = None, + request_to_port_router: Callable[[Request], int] | None = None, + http2_ports: list[int] | None = None, + volumes: list[SimpleVolumeBind] | None = None, + env_vars: dict[str, str] | None = None, + health_check_path: str = "/__admin/health", + health_check_port: int | None = None, + health_check_retries: int = 40, + health_check_sleep: float = 1, + ): + self.image_name = image_name + self.container_ports = container_ports + self.host = host + self.path = path + self.container_name = container_name + self.command = command + self.request_to_port_router = request_to_port_router + self.http2_ports = http2_ports + self.volumes = volumes + self.env_vars = env_vars + self.health_check_path = health_check_path + self.health_check_port = health_check_port + self.health_check_retries = health_check_retries + self.health_check_sleep = health_check_sleep + + def update_gateway_routes(self, router: http.Router[http.RouteHandler]): + if self.path: + raise NotImplementedError( + "Path-based routing not yet implemented for this extension" + ) + self.start_container() + # add resource for HTTP/1.1 requests + resource = RuleAdapter(ProxyResource(self)) + if self.host: + resource = WithHost(self.host, [resource]) + router.add(resource) + + def on_platform_shutdown(self): + self._remove_container() + + def _get_container_name(self) -> str: + if self.container_name: + return self.container_name + name = f"ls-ext-{self.name}" + name = re.sub(r"\W", "-", name) + return name + + @cache + def start_container(self) -> None: + container_name = self._get_container_name() + LOG.debug("Starting extension container %s", container_name) + + ports = PortMappings() + for port in self.container_ports: + ports.add(port) + + kwargs = {} + if self.command: + kwargs["command"] = self.command + if self.env_vars: + kwargs["env_vars"] = self.env_vars + + try: + DOCKER_CLIENT.run_container( + self.image_name, + detach=True, + remove=True, + name=container_name, + ports=ports, + volumes=self.volumes, + **kwargs, + ) + except Exception as e: + LOG.debug("Failed to start container %s: %s", container_name, e) + raise + + health_port = self.health_check_port or self.container_ports[0] + container_host = get_addressable_container_host() + health_url = f"http://{container_host}:{health_port}{self.health_check_path}" + + def _ping_endpoint(): + LOG.debug("Health check: %s", health_url) + response = requests.get(health_url, timeout=5) + assert response.ok + + try: + retry( + _ping_endpoint, + retries=self.health_check_retries, + sleep=self.health_check_sleep, + ) + except Exception as e: + LOG.info("Failed to connect to container %s: %s", container_name, e) + # Log container output for debugging + try: + logs = DOCKER_CLIENT.get_container_logs(container_name) + LOG.info("Container logs for %s:\n%s", container_name, logs) + except Exception: + pass + self._remove_container() + raise + + LOG.debug("Successfully started extension container %s", container_name) + + def _remove_container(self): + container_name = self._get_container_name() + LOG.debug("Stopping extension container %s", container_name) + DOCKER_CLIENT.remove_container( + container_name, force=True, check_existence=False + ) + + +class ProxyResource: + """ + Simple proxy resource that forwards incoming requests from the + LocalStack Gateway to the target Docker container. + """ + + extension: ProxiedDockerContainerExtension + + def __init__(self, extension: ProxiedDockerContainerExtension): + self.extension = extension + + @route("/") + def index(self, request: Request, path: str, *args, **kwargs): + return self._proxy_request(request, forward_path=f"/{path}") + + def _proxy_request(self, request: Request, forward_path: str, *args, **kwargs): + self.extension.start_container() + + port = self.extension.container_ports[0] + container_host = get_addressable_container_host() + base_url = f"http://{container_host}:{port}" + proxy = Proxy(forward_base_url=base_url) + + # update content length (may have changed due to content compression) + if request.method not in ("GET", "OPTIONS"): + request.headers["Content-Length"] = str(len(request.data)) + + # make sure we're forwarding the correct Host header + request.headers["Host"] = f"localhost:{port}" + + # forward the request to the target + result = proxy.forward(request, forward_path=forward_path) + + return result diff --git a/wiremock/pyproject.toml b/wiremock/pyproject.toml new file mode 100644 index 0000000..2ee19c8 --- /dev/null +++ b/wiremock/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools", "wheel", "plux>=1.3.1"] +build-backend = "setuptools.build_meta" + +[project] +name = "localstack-wiremock" +version = "0.1.0" +description = "WireMock Extension for LocalStack" +readme = {file = "README.md", content-type = "text/markdown; charset=UTF-8"} +requires-python = ">=3.9" +authors = [ + { name = "LocalStack + WireMock team"} +] +keywords = ["LocalStack", "WireMock"] +classifiers = [] +dependencies = [ + "priority" +] + +[project.urls] +Homepage = "https://github.com/localstack/localstack-extensions/tree/main/wiremock" + +[project.optional-dependencies] +dev = [ + "boto3", + "build", + "jsonpatch", + "localstack", + "pytest", + "rolo", + "ruff", + "twisted" +] + +[project.entry-points."localstack.extensions"] +localstack-wiremock = "localstack_wiremock.extension:WireMockExtension" diff --git a/wiremock/sample-app/main.tf b/wiremock/sample-app/main.tf new file mode 100644 index 0000000..b42aec5 --- /dev/null +++ b/wiremock/sample-app/main.tf @@ -0,0 +1,152 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +output "api_endpoint" { + description = "The invoke URL for the deployed API stage." + value = "${aws_api_gateway_stage.dev_stage.invoke_url}/${aws_api_gateway_resource.time_off_resource.path_part}" +} + +variable "aws_region" { + description = "The AWS region to deploy the resources in." + type = string + default = "us-east-1" +} + +# 1. Package the Lambda function code +resource "null_resource" "package_lambda" { + triggers = { + handler_hash = filebase64sha256("${path.module}/src/handler.py") + requirements_hash = filebase64sha256("${path.module}/src/requirements.txt") + } + + provisioner "local-exec" { + command = <<-EOT + rm -rf "${path.module}/build" + mkdir -p "${path.module}/build" + cp "${path.module}/src/handler.py" "${path.module}/build/" + pip3 install -r "${path.module}/src/requirements.txt" -t "${path.module}/build" + EOT + interpreter = ["/bin/bash", "-c"] + } +} + +data "archive_file" "lambda_zip" { + type = "zip" + source_dir = "${path.module}/build" + output_path = "${path.module}/lambda_function.zip" + depends_on = [null_resource.package_lambda] +} + +# 2. Create an IAM role for the Lambda function +resource "aws_iam_role" "lambda_exec_role" { + name = "hr_info_lambda_exec_role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) +} + +# 3. Attach the basic Lambda execution policy to the role +resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { + role = aws_iam_role.lambda_exec_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# 4. Create the Lambda function +resource "aws_lambda_function" "hr_info_lambda" { + function_name = "hr_info_lambda" + role = aws_iam_role.lambda_exec_role.arn + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + handler = "handler.get_time_off" + runtime = "python3.9" + + # Add a timeout for the function + timeout = 10 +} + +# 5. Create an API Gateway REST API +resource "aws_api_gateway_rest_api" "hr_api" { + name = "hr_info_api" + description = "API for retrieving company HR information" +} + +# 6. Create a resource in the API (e.g., /time-off) +resource "aws_api_gateway_resource" "time_off_resource" { + rest_api_id = aws_api_gateway_rest_api.hr_api.id + parent_id = aws_api_gateway_rest_api.hr_api.root_resource_id + path_part = "time-off" +} + +# 7. Create a GET method for the /time-off resource +resource "aws_api_gateway_method" "get_method" { + rest_api_id = aws_api_gateway_rest_api.hr_api.id + resource_id = aws_api_gateway_resource.time_off_resource.id + http_method = "GET" + authorization = "NONE" +} + +# 8. Integrate the GET method with the Lambda function +resource "aws_api_gateway_integration" "lambda_integration" { + rest_api_id = aws_api_gateway_rest_api.hr_api.id + resource_id = aws_api_gateway_resource.time_off_resource.id + http_method = aws_api_gateway_method.get_method.http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.hr_info_lambda.invoke_arn +} + +# 9. Grant API Gateway permission to invoke the Lambda function +resource "aws_lambda_permission" "api_gateway_permission" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.hr_info_lambda.function_name + principal = "apigateway.amazonaws.com" + + source_arn = "${aws_api_gateway_rest_api.hr_api.execution_arn}/*/${aws_api_gateway_method.get_method.http_method}${aws_api_gateway_resource.time_off_resource.path}" +} + +# 10. Deploy the API +resource "aws_api_gateway_deployment" "api_deployment" { + rest_api_id = aws_api_gateway_rest_api.hr_api.id + + # Terraform needs a trigger to create a new deployment + triggers = { + redeployment = sha1(jsonencode([ + aws_api_gateway_resource.time_off_resource.id, + aws_api_gateway_method.get_method.id, + aws_api_gateway_integration.lambda_integration.id, + ])) + } + + lifecycle { + create_before_destroy = true + } +} + +# 11. Create a stage for the deployment +resource "aws_api_gateway_stage" "dev_stage" { + deployment_id = aws_api_gateway_deployment.api_deployment.id + rest_api_id = aws_api_gateway_rest_api.hr_api.id + stage_name = "dev" +} diff --git a/wiremock/sample-app/src/handler.py b/wiremock/sample-app/src/handler.py new file mode 100644 index 0000000..fc31bb1 --- /dev/null +++ b/wiremock/sample-app/src/handler.py @@ -0,0 +1,58 @@ +import json +import requests + + +def get_time_off(event, context): + """ + Handles the API Gateway event, fetches data from the mock Personio API, + and returns a transformed JSON response. + """ + try: + # Define the mock API endpoint URL (hardcoding `wiremock.localhost.localstack.cloud` for + # local dev for now - could be injected via env variables in the future ...) + url = "http://wiremock.localhost.localstack.cloud:4566/company/time-offs/534813865" + + # Make a GET request to the mock API + response = requests.get(url, timeout=5) + response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx) + + data = response.json() + + # Safely extract data using .get() to avoid KeyErrors + employee_data = data.get("data", {}).get("employee", {}).get("attributes", {}) + time_off_attributes = data.get("data", {}).get("attributes", {}) + + # Transform the data into the desired response format + transformed_data = { + "employee_name": f"{employee_data.get('first_name', 'N/A')} {employee_data.get('last_name', 'N/A')}", + "time_off_date": f"{time_off_attributes.get('start_date', 'N/A')} to {time_off_attributes.get('end_date', 'N/A')}", + "approval_status": time_off_attributes.get("status", "N/A"), + } + + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(transformed_data), + } + + except requests.exceptions.RequestException as e: + # Handle network-related errors (e.g., connection refused, timeout) + error_message = { + "message": "Could not connect to the downstream HR service.", + "error": str(e), + } + print("Error:", error_message) + return { + "statusCode": 503, # Service Unavailable + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(error_message), + } + except Exception as e: + # Handle other unexpected errors (e.g., JSON parsing issues, programming errors) + error_message = {"message": "An unexpected error occurred.", "error": str(e)} + print("Error:", error_message) + return { + "statusCode": 500, # Internal Server Error + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(error_message), + } diff --git a/wiremock/sample-app/src/requirements.txt b/wiremock/sample-app/src/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/wiremock/sample-app/src/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0