Skip to content

Project Templates

craft-easy-template is a cookiecutter repository that scaffolds ready-to-run Craft Easy services. It ships two templates — one for a Python API service and one for a React/Expo frontend app — each with its own prompts, generated layout, and post-generation hooks.

Use the templates when you want a new project that already has application wiring, infrastructure, and CI assembled. If you prefer to build from scratch, see Quick Start instead.

Installing cookiecutter

pip install cookiecutter

Run a template straight from GitHub, or point at a local clone:

# From GitHub
cookiecutter gh:easy-software-system/craft-easy-template --directory=api
cookiecutter gh:easy-software-system/craft-easy-template --directory=frontend

# From a local clone
cookiecutter /path/to/craft-easy-template --directory=api
cookiecutter /path/to/craft-easy-template --directory=frontend

The API template

Scaffolds a complete Python service: FastAPI app factory, Craft Easy settings, a sample resource, Docker Compose and Dockerfile, Terraform infrastructure for your chosen cloud, and a working test suite with GitHub Actions CI.

Prompts

All values have sensible defaults — press enter to accept them.

Prompt Default Description
project_name My API Human-readable name, used in README.md, APP_TITLE, and the settings class name.
project_slug my-api Directory name and Docker image name. Must match ^[a-z][a-z0-9-]*$.
package_name my_api Python package name used in imports. Must match ^[a-z][a-z0-9_]*$.
description A REST API built on Craft Easy Written to pyproject.toml and README.md.
author_name Your Name Written to pyproject.toml.
author_email you@example.com Written to pyproject.toml.
python_version 3.12 Minimum Python version pinned in pyproject.toml and the Dockerfile.
craft_easy_version >=0.1 Version constraint for the craft-easy-api dependency.
database_uri mongodb://localhost:27017 MongoDB URI written to .env.example.
database_name {package_name}_dev Database name written to .env.example and docker-compose.yml.
auth_method jwt | oauth2 | otp Pre-configures the auth setting in .env.example; the first option is the default.
cloud_provider azure | gcp | none Selects which Terraform configuration and deployment workflow to generate.
cloud_region_azure swedencentral Azure region used as the default in variables.tf.
cloud_region_gcp europe-north1 GCP region used as the default in variables.tf.
enable_jobs_deployment no | yes If yes, retains infrastructure/jobs.tf; otherwise that file is removed by the post-generation hook. Requires enable_jobs=yes.

Feature flags

Six yes/no prompts toggle optional subsystems. Each flag bakes a True/False constant into the generated settings.py. Flags you say no to leave no trace in the generated project.

Flag Default Effect
enable_jobs no Sets JOBS_ENABLED in settings.py and .env.example.
enable_file_import no Sets FILE_IMPORT_ENABLED.
enable_payments no Sets PAYMENTS_ENABLED.
enable_bookkeeping no Sets BOOKKEEPING_ENABLED.
enable_gdpr no Sets GDPR_ENABLED.
enable_bi_export no Sets BI_EXPORT_ENABLED.

Input validation

A pre-generation hook runs before any files are written. It rejects invalid values and exits with a clear error message:

  • project_slug must match ^[a-z][a-z0-9-]*$
  • package_name must match ^[a-z][a-z0-9_]*$
  • cloud_provider must be one of azure, gcp, or none

What you get

The annotated file tree below shows the result for a project named My API with azure as the cloud provider and all feature flags left at their defaults (no).

my-api/
├── .env.example              # Environment variable reference — copy to .env
├── .gitignore
├── .github/
│   └── workflows/
│       ├── ci.yml            # Lint, format-check, and pytest on push/PR
│       └── deploy-azure.yml  # Container image build + Terraform apply (Azure)
│                             # deploy-gcp.yml is generated instead for GCP;
│                             # both are removed when cloud_provider=none
├── Dockerfile                # Multi-stage production image, runs as non-root
├── docker-compose.yml        # MongoDB + API with hot-reload for local development
├── pyproject.toml            # Build system, dependencies, ruff, pytest config
├── README.md
├── infrastructure/           # Omitted when cloud_provider=none
│   ├── backend.tf
│   ├── main.tf               # Terraform root: provider, modules, resources
│   ├── variables.tf          # env, image_tag, region, and provider-specific vars
│   ├── terraform.tfvars.example
│   ├── README.md
│   ├── environments/
│   │   ├── dev.tfvars
│   │   └── prod.tfvars
│   └── modules/              # Copied from shared/infrastructure-modules/ by hook
│       ├── azure/            # Container Registry, Container Apps, Cosmos DB, …
│       └── cloudflare/       # Cloudflare modules (bundled with azure and gcp)
├── src/
│   └── my_api/
│       ├── __init__.py
│       ├── app.py            # create_my_api_app() factory
│       ├── settings.py       # MyAPISettings(BaseSettings)
│       ├── models/
│       │   ├── __init__.py
│       │   └── example.py    # Sample TenantScopedDocument — remove or replace
│       └── routes/
│           └── __init__.py   # Router stub — add your route modules here
└── tests/
    ├── __init__.py
    └── test_health.py        # Async ASGI health-check test

The generated pyproject.toml

[project]
name = "my-api"
version = "0.1.0"
description = "A REST API built on Craft Easy"
readme = "README.md"
license = "MIT"
requires-python = ">=3.12"
authors = [
    {name = "Your Name", email = "you@example.com"},
]

dependencies = [
    "craft-easy-api>=0.1",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.24",
    "httpx>=0.28",
    "ruff>=0.8",
    "mongomock-motor>=0.0.34",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/my_api"]

[tool.ruff]
target-version = "py312"
line-length = 100

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

The dev extra installs everything needed for testing (real async HTTP client, in-memory MongoDB via mongomock-motor) and linting. CI runs pip install -e ".[dev]" so the local editable install and the pipeline share the exact same environment.

The generated settings.py

from craft_easy.settings import Settings as BaseSettings

class MyAPISettings(BaseSettings):
    APP_TITLE: str = "My API"
    APP_VERSION: str = "0.1.0"
    DATABASE_NAME: str = "my_api_dev"

    # Baked-in at generation time — override via environment variables
    JOBS_ENABLED: bool = False
    FILE_IMPORT_ENABLED: bool = False
    PAYMENTS_ENABLED: bool = False
    BOOKKEEPING_ENABLED: bool = False
    GDPR_ENABLED: bool = False
    BI_EXPORT_ENABLED: bool = False

BaseSettings (from craft_easy.settings) is a Pydantic BaseSettings subclass that reads from environment variables and a .env file automatically. Every field defined in BaseSettingsMONGODB_URI, AUTH_ENABLED, ACCESS_CONTROL_ENABLED, LOG_LEVEL, and so on — is inherited and available without repeating it here.

The class name is derived by stripping spaces from project_name: My APIMyAPISettings, Order ServiceOrderServiceSettings.

Module flags (e.g. JOBS_ENABLED) are baked in as False at generation time when you answered no to the corresponding prompt. They can still be overridden at runtime via environment variables — the generated .env.example includes commented-out entries for each flag.

The generated app.py

from craft_easy import create_app
from craft_easy.core.crud import Resource

from .models.example import Example
from .settings import MyAPISettings

def create_my_api_app(settings=None):
    if settings is None:
        settings = MyAPISettings()

    app = create_app(settings)

    # --- Resources ---
    app.include_router(Resource(Example, "examples").build())

    # Add more resources here:
    # from .models.your_model import YourModel
    # app.include_router(Resource(YourModel, "your-models").build())

    return app

# Uvicorn entry point: uvicorn my_api.app:app --factory
app = create_my_api_app

Resource(model, path).build() returns a FastAPI router with standard CRUD, pagination, and ETag concurrency wired up. Add more resource routers the same way. See Resources for the full API.

The generated example.py model

from typing import Optional
from craft_easy.models.base import BaseDocument
from pydantic import Field

class Example(BaseDocument):
    name: str = Field(description="Name of the example item")
    description: Optional[str] = Field(default=None, description="Optional description")
    is_active: bool = Field(default=True, description="Whether the item is active")

    class TenantConfig:
        tenant_scoped = True

    class Settings:
        name = "examples"
        use_revision = True
        indexes = [
            [("name", 1), ("tenant_id", 1)],
        ]

Replace or delete this model — it exists purely to show the pattern and to give the health-check test a concrete resource to exercise.

Post-generation hook

After cookiecutter writes the project tree, a post-generation hook runs automatically:

  1. Cloud module copy — if cloud_provider is azure or gcp, the hook locates craft-easy-template/shared/infrastructure-modules/ in the template source tree and copies the relevant modules into infrastructure/modules/. Cloudflare modules are always included alongside the cloud-provider modules.
  2. Workflow pruning — deployment workflows for clouds you did not select are deleted. An azure project keeps only deploy-azure.yml; a gcp project keeps only deploy-gcp.yml; a none project removes both.
  3. Infrastructure removal — if cloud_provider=none, the entire infrastructure/ directory and both deployment workflows are removed.
  4. Jobs deployment pruning — if enable_jobs=no or enable_jobs_deployment=no, the infrastructure/jobs.tf file is removed so Terraform does not reference undefined job resources.

Running the generated project

cd my-api

# Create a virtual environment and install dependencies
python3.12 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

# Start MongoDB (or bring up the full stack)
docker compose up -d mongodb
# — or —
docker compose up      # starts both mongodb and api with hot-reload

# Configure local environment
cp .env.example .env
# Edit .env if you need non-default values

# Run the API directly (when using docker compose up -d mongodb)
uvicorn my_api.app:app --factory --host 0.0.0.0 --port 8000

Open http://localhost:8000/docs for the interactive OpenAPI UI.

Verifying the result

# Health check
curl http://localhost:8000/health
# → {"status": "ok"}

# List examples resource (returns empty array on a fresh install)
curl http://localhost:8000/api/v1/examples
# → {"items": [], "total": 0, ...}

# Run the test suite
pytest -v

Task management

Every Craft Easy project is expected to track work in a tasks/ directory. The template repository ships a reference structure at craft-easy-template/tasks/ — including BACKLOG.md and re-usable task spec templates in tasks/templates/. Copy this directory into your generated project at creation time and commit it:

cp -r /path/to/craft-easy-template/tasks my-api/tasks

From that point forward, create and track new work items in tasks/BACKLOG.md using the templates in tasks/templates/.


The frontend template

Scaffolds an Expo app with Expo Router navigation, JWT or API-key authentication, schema-driven resource views, and a pre-configured Craft Easy API client.

Prompts

Prompt Default Description
project_name My App Human-readable app name, written to app.json and package.json.
project_slug Derived from project_name Directory and npm package name. Spaces become hyphens.
api_url http://localhost:5001 Default API base URL wired into the generated API client.
auth_method jwt | api_key Selects which auth flow is pre-wired in AuthContext.tsx.
description A Craft Easy frontend application Written to package.json.
author_name Developer Written to package.json.
cloud_provider azure | gcp | none Selects hosting infrastructure for the web build.
cloud_region_azure swedencentral Azure region for Static Web Apps.
cloud_region_gcp europe-north1 GCP region for Cloud Run / Storage.

Post-generation hook

The frontend hook prunes deployment workflows for the clouds you did not select, identical to the API template hook.

What you get

my-app/
├── .gitignore
├── app.json                    # Expo configuration (name, slug, icon, etc.)
├── package.json
├── tsconfig.json               # TypeScript strict mode
├── app/                        # Expo Router file-system routes
│   ├── _layout.tsx             # Root layout — wraps AuthContext and ThemeContext
│   ├── index.tsx               # Redirects to /login or /(main)
│   ├── login.tsx               # Login screen (JWT token exchange or API key entry)
│   └── (main)/
│       ├── _layout.tsx         # Tab bar navigator
│       ├── index.tsx           # Home / dashboard
│       └── [resource]/
│           ├── index.tsx       # Resource list, driven by /admin/schema
│           └── [id].tsx        # Resource detail and inline edit
└── src/
    ├── context/
    │   ├── AuthContext.tsx     # Auth state, token storage, refresh logic
    │   └── ThemeContext.tsx    # Light / dark palette with system-preference detection
    ├── hooks/
    │   └── useResource.ts     # CRUD hook backed by API client and local cache
    ├── lib/
    │   ├── api-client.ts      # fetch wrapper with auth, refresh, ETag, request IDs
    │   └── schema.ts          # /admin/schema loader and in-memory cache
    └── types/
        └── schema.ts          # TypeScript types for the Craft Easy schema envelope

The API client

src/lib/api-client.ts is a typed fetch wrapper that handles:

  • Authentication — attaches Authorization: Bearer <token> from secure storage for jwt, or the API key header for api_key.
  • Token refresh — on 401, calls the refresh endpoint and retries the original request once.
  • ETag concurrency — captures ETag headers on GET and sends If-Match on the next PATCH/PUT/DELETE for the same document.
  • Request IDs — forwards X-Request-Id to the API for server-side log correlation.
  • Error surface — rejects with a typed ApiError carrying code, message, and details from the Craft Easy error envelope.

The useResource hook

import { useResource } from "@/src/hooks/useResource";

export function ProductList() {
  const { items, isLoading, refresh, create, update, remove } = useResource("products");

  if (isLoading) return <ActivityIndicator />;
  return <FlatList data={items} renderItem={...} />;
}

useResource calls the Craft Easy REST API, exposes loading and error states, and paginates automatically. Multiple components calling useResource("products") share the same in-memory cache.

Schema-driven views

The generated [resource]/index.tsx and [resource]/[id].tsx screens call GET /admin/schema on startup to discover resource names, field types, and display labels. They render list and detail views without any resource-specific UI code. As you add resources to the API, the frontend discovers them automatically.

Running the generated project

cd my-app
npm install
npm run web       # Expo web (browser)
npm run ios       # iOS simulator
npm run android   # Android emulator

Updating a generated project

The templates are a starting point, not a framework you inherit from. Once cookiecutter has run, the generated code is yours — edit it freely. If craft-easy-template ships new features later, pull them in selectively rather than regenerating the whole project.

Source reference

File Purpose
api/cookiecutter.json API template prompts and defaults
api/{{cookiecutter.project_slug}}/ API template body
api/hooks/pre_gen_project.py Input validation (slug, package name, cloud provider)
api/hooks/post_gen_project.py Cloud module copy, workflow and infrastructure pruning
frontend/cookiecutter.json Frontend template prompts and defaults
frontend/{{cookiecutter.project_slug}}/ Frontend template body
frontend/hooks/post_gen_project.py Deployment workflow pruning
shared/infrastructure-modules/ Shared Terraform modules (Azure, GCP, Cloudflare)
tasks/ Reference BACKLOG.md and task spec templates