Skip to content
Open
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
18 changes: 17 additions & 1 deletion .github/actions/setup-copier-template/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ inputs:
template-cloud-service:
required: true
description: Template cloud service
resources-fixture:
required: false
default: "single"
description: >-
Which resources fixture to render: "single" (the default single resource
derived from the project name) or "multi" (two resources sharing one
container with different operation subsets).
output-dir:
required: false
default: "KittenClaws"
description: Directory to render the generated project into.

runs:
using: "composite"
Expand All @@ -28,6 +39,10 @@ runs:

- name: Create Copier Template
run: |
extra_args=()
if [ "${{ inputs.resources-fixture }}" = "multi" ]; then
extra_args+=(--data-file "${{ github.action_path }}/fixtures/multi-resources.yml")
fi
copier copy --defaults --trust \
--data project_name="KittenClaws" \
--data project_description="Kitties got claws...beware" \
Expand All @@ -38,5 +53,6 @@ runs:
--data author="Sir Meowsalots" \
--data cloud_service="${{ inputs.template-cloud-service }}" \
--data open_source_license="MIT license" \
./${{ inputs.template-language }} ./KittenClaws
"${extra_args[@]}" \
./${{ inputs.template-language }} ./${{ inputs.output-dir }}
shell: bash
15 changes: 15 additions & 0 deletions .github/actions/setup-copier-template/fixtures/multi-resources.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Multi-resource showcase for the published example branches.
#
# Two resources sharing a single storage container ("animals") with different
# operation subsets — Cat exposes PATCH (update) while Dog exposes PUT
# (replace) — to demonstrate configurable endpoints, shared storage, and
# per-operation method subsetting in one generated project.
resources:
- name: "Cat"
endpoint: "cats"
container: "animals"
operations: ["list", "get_by_id", "create", "update", "delete"]
- name: "Dog"
endpoint: "dogs"
container: "animals"
operations: ["list", "get_by_id", "create", "replace", "delete"]
163 changes: 163 additions & 0 deletions .github/workflows/publish-examples.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
name: Publish Example Branches

# Renders template combinations (language x cloud x resources fixture) with
# Copier and force-publishes each generated project to its own example branch,
# so the generated output is browsable without running Copier locally.
#
# Branch naming:
# push to main -> example/<language>-<cloud>-<fixture>
# pull request -> example/pr-<number>-<language>-<cloud>-<fixture>
#
# On a pull request only the language(s) whose template dir changed are
# rendered (so a Python PR publishes only example/pr-<n>-python-* branches);
# pushes to main render all four languages.
#
# Full-fidelity publishing (including each generated project's own
# .github/workflows/build-pipeline.yml) requires a Personal Access Token with
# `repo` + `workflow` scopes stored as the `EXAMPLES_PUBLISH_TOKEN` secret. When
# that secret is absent the job falls back to the default GITHUB_TOKEN, which is
# not permitted to push workflow files — so the generated workflows are
# relocated to `.github/workflows-example/` and a note is added.

on:
push:
branches:
- main
paths:
- go/**
- python/**
- typescript/**
- dotnet/**
- .github/workflows/publish-examples.yml
- .github/actions/setup-copier-template/**
pull_request:
branches:
- main
paths:
- go/**
- python/**
- typescript/**
- dotnet/**
- .github/workflows/publish-examples.yml
- .github/actions/setup-copier-template/**
workflow_dispatch:

permissions:
contents: write

concurrency:
group: publish-examples-${{ github.ref }}
cancel-in-progress: true

jobs:
resolve:
runs-on: ubuntu-latest
outputs:
languages: ${{ steps.pick.outputs.languages }}
steps:
- name: Check out
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Pick languages to render
id: pick
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
all=(go python typescript dotnet)
sel=()
if [ "${EVENT}" = "pull_request" ]; then
changed=$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" || true)
for l in "${all[@]}"; do
if printf '%s\n' "${changed}" | grep -qE "^${l}/"; then
sel+=("${l}")
fi
done
# If only shared files changed (e.g. the workflow itself), render all.
if [ ${#sel[@]} -eq 0 ]; then sel=("${all[@]}"); fi
else
sel=("${all[@]}")
fi
json=$(printf '%s\n' "${sel[@]}" | jq -R . | jq -cs .)
echo "languages=${json}" >> "$GITHUB_OUTPUT"
echo "Selected languages: ${json}"

publish-examples:
needs: resolve
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ${{ fromJSON(needs.resolve.outputs.languages) }}
cloud:
- name: "Azure Function App"
slug: azure
- name: "GCP Cloud Function"
slug: gcp
- name: "AWS Lambda"
slug: aws
fixture:
- single
- multi
steps:
- name: Check out
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Render example project
uses: ./.github/actions/setup-copier-template
with:
template-language: ${{ matrix.language }}
template-cloud-service: ${{ matrix.cloud.name }}
resources-fixture: ${{ matrix.fixture }}
output-dir: _example

- name: Publish to example branch
env:
PUBLISH_TOKEN: ${{ secrets.EXAMPLES_PUBLISH_TOKEN || github.token }}
HAS_PAT: ${{ secrets.EXAMPLES_PUBLISH_TOKEN != '' }}
BRANCH: ${{ github.event_name == 'pull_request' && format('example/pr-{0}-{1}-{2}-{3}', github.event.pull_request.number, matrix.language, matrix.cloud.slug, matrix.fixture) || format('example/{0}-{1}-{2}', matrix.language, matrix.cloud.slug, matrix.fixture) }}
COMBO: "${{ matrix.language }} / ${{ matrix.cloud.name }} / ${{ matrix.fixture }}"
SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
set -euo pipefail
cd _example

# The default GITHUB_TOKEN cannot push files under .github/workflows/.
# Without a publish PAT, relocate the generated workflows so the
# example branch can still be published.
if [ "${HAS_PAT}" != "true" ] && [ -d .github/workflows ]; then
mkdir -p .github/workflows-example
mv .github/workflows/* .github/workflows-example/
rmdir .github/workflows
{
printf '%s\n' \
'# Generated example' \
'' \
'Published automatically by the `Publish Example Branches` workflow.' \
'' \
'The generated CI lives in `.github/workflows-example/` instead of' \
'`.github/workflows/` because the default Actions token cannot publish' \
'workflow files. Configure an `EXAMPLES_PUBLISH_TOKEN` PAT (with `repo`' \
'and `workflow` scopes) to publish these examples with full fidelity.'
} > EXAMPLES_NOTE.md
fi

git init -q
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -q -b "${BRANCH}"
git add -A
git commit -q \
-m "chore(examples): ${COMBO}" \
-m "Generated by Copier from ${SOURCE_SHA}."
git push -q --force \
"https://x-access-token:${PUBLISH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
"HEAD:refs/heads/${BRANCH}"

echo "Published \`${BRANCH}\`" >> "$GITHUB_STEP_SUMMARY"
48 changes: 48 additions & 0 deletions python/copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,51 @@ project_class_name:
type: str
when: false
default: "{{ project_name | to_camel }}"

# ---- REST resources ----
# A list of resources to generate. Each resource gets its own URL endpoint, a
# storage container (resources sharing a container id share storage), and a
# chosen subset of operations. The default is a single resource matching the
# project name with today's CRUD behaviour, so answering only project_name
# reproduces the original single-resource project.
resources:
type: yaml
multiline: true
help: >-
Resources to generate. Each item: name (PascalCase), endpoint (kebab-case URL
segment), container (storage id; equal ids share storage), operations (subset of
list, get_by_id, create, update, replace, delete).
default: |
- name: "{{ project_class_name }}"
endpoint: "{{ project_endpoint }}"
container: "{{ project_slug }}"
operations: ["list", "get_by_id", "create", "update", "delete"]
validator: >-
{%- set valid_ops = ["list", "get_by_id", "create", "update", "replace", "delete"] -%}
{%- set ns = namespace(err="", eps=[], names=[]) -%}
{%- if resources | length < 1 -%}
{%- set ns.err = "At least one resource is required." -%}
{%- else -%}
{%- for r in resources -%}
{%- if not ns.err -%}
{%- if not r.name or not (r.name | string | regex_search('^[A-Z][A-Za-z0-9]*$')) -%}
{%- set ns.err = "Resource name '" ~ (r.name | default('')) ~ "' must be PascalCase." -%}
{%- elif not r.endpoint or not (r.endpoint | string | regex_search('^[a-z0-9][a-z0-9\-]*$')) -%}
{%- set ns.err = "Resource endpoint '" ~ (r.endpoint | default('')) ~ "' must be a kebab-case URL segment." -%}
{%- elif not r.container or not (r.container | string | regex_search('^[A-Za-z0-9_\-]+$')) -%}
{%- set ns.err = "Resource container '" ~ (r.container | default('')) ~ "' has an invalid id." -%}
{%- elif (r.operations | default([]) | length) < 1 -%}
{%- set ns.err = "Resource '" ~ r.name ~ "' must enable at least one operation." -%}
{%- elif r.operations | reject('in', valid_ops) | list | length > 0 -%}
{%- set ns.err = "Resource '" ~ r.name ~ "' has an invalid operation. Allowed: " ~ (valid_ops | join(', ')) ~ "." -%}
{%- elif r.endpoint in ns.eps -%}
{%- set ns.err = "Duplicate endpoint '" ~ r.endpoint ~ "'." -%}
{%- elif r.name in ns.names -%}
{%- set ns.err = "Duplicate resource name '" ~ r.name ~ "'." -%}
{%- endif -%}
{%- set ns.eps = ns.eps + [r.endpoint] -%}
{%- set ns.names = ns.names + [r.name] -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{{ ns.err }}
8 changes: 5 additions & 3 deletions python/template/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ run: ## Run function app.
@poetry run func start
{%- endif %}
{% if cloud_service == 'GCP Cloud Function' -%}
@echo "🚀 Running Cloud Function (get_list endpoint)"
@echo "Use 'poetry run functions-framework --target=<function_name> --source=main.py' to run specific functions"
@poetry run functions-framework --target=get_list --source=main.py --port=8080
{%- set op_fn = {'list': 'get_list', 'get_by_id': 'get_by_id', 'create': 'create', 'update': 'update', 'replace': 'replace', 'delete': 'delete'} -%}
{%- set first_fn = op_fn[resources[0].operations[0]] ~ '_' ~ (resources[0].name | to_snake) -%}
@echo "🚀 Running Cloud Function ({{ first_fn }})"
@echo "Each function deploys separately. Use 'poetry run functions-framework --target=<function_name> --source=main.py' to run a specific one."
@poetry run functions-framework --target={{ first_fn }} --source=main.py --port=8080
{%- endif %}
{% if cloud_service == 'AWS Lambda' -%}
@echo "🚀 Building and running Lambda API locally via SAM"
Expand Down
60 changes: 41 additions & 19 deletions python/template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,28 @@ This project is a Python-based REST API built using [Google Cloud Functions](htt
This project is a Python-based REST API built using [AWS Lambda](https://docs.aws.amazon.com/lambda/) with API Gateway. The API leverages AWS's serverless architecture, allowing you to deploy and scale functions effortlessly in the cloud. The HTTP-triggered Lambda functions serve as the endpoints for the API, providing a seamless way to handle client requests.
{%- endif %}

The REST API has the following endpoints:
- GET (by ID)
- GET (list)
- POST
- PATCH
- DELETE (soft-delete)
The REST API exposes the following resources and operations:
{% for resource in resources %}
- **`/{{ resource.endpoint }}`** (container: `{{ resource.container }}`)
{%- if "list" in resource.operations %}
- `GET /{{ resource.endpoint }}` — list
{%- endif %}
{%- if "get_by_id" in resource.operations %}
- `GET /{{ resource.endpoint }}/{item_id}` — get by ID
{%- endif %}
{%- if "create" in resource.operations %}
- `POST /{{ resource.endpoint }}` — create
{%- endif %}
{%- if "update" in resource.operations %}
- `PATCH /{{ resource.endpoint }}/{item_id}` — partial update
{%- endif %}
{%- if "replace" in resource.operations %}
- `PUT /{{ resource.endpoint }}/{item_id}` — full replace
{%- endif %}
{%- if "delete" in resource.operations %}
- `DELETE /{{ resource.endpoint }}/{item_id}` — soft delete
{%- endif %}
{%- endfor %}

{% if cloud_service == 'Azure Function App' -%}
Dependency management is handled using [Poetry](https://python-poetry.org/), ensuring a streamlined and consistent environment for managing Python packages and their dependencies.
Expand Down Expand Up @@ -154,34 +170,40 @@ Dependency management is handled using [Poetry](https://python-poetry.org/), ens
Set the following environment variables for local development:
- `GCP_PROJECT_ID`: Your GCP project ID
- `FIRESTORE_DATABASE`: Firestore database name (defaults to "(default)")
- `FIRESTORE_COLLECTION`: Firestore collection name (defaults to "{{ project_slug }}")
{%- for container in resources | map(attribute='container') | unique %}
- `FIRESTORE_COLLECTION_{{ container | upper | replace('-', '_') }}`: Firestore collection for the `{{ container }}` container (defaults to "{{ container }}")
{%- endfor %}

5. Run the API Locally

To run a specific function locally using Functions Framework:
Each operation deploys as its own function. Run a specific one locally using Functions Framework:

```console
# Run the get_list function
poetry run functions-framework --target=get_list --source=main.py --port=8080
# Or run other functions
poetry run functions-framework --target=get_by_id --source=main.py --port=8080
poetry run functions-framework --target=create --source=main.py --port=8080
{%- set op_fn = {'list': 'get_list', 'get_by_id': 'get_by_id', 'create': 'create', 'update': 'update', 'replace': 'replace', 'delete': 'delete'} %}
{%- for resource in resources %}
{%- for op in resource.operations %}
poetry run functions-framework --target={{ op_fn[op] }}_{{ resource.name | to_snake }} --source=main.py --port=8080
{%- endfor %}
{%- endfor %}
```

6. Deploy to GCP

Deploy individual functions to GCP Cloud Functions:
Deploy each function to GCP Cloud Functions:

```console
# Deploy the get_list function
gcloud functions deploy get_list \
{%- for resource in resources %}
{%- for op in resource.operations %}
{%- set fn = op_fn[op] ~ '_' ~ (resource.name | to_snake) %}
gcloud functions deploy {{ fn }} \
--runtime python313 \
--trigger-http \
--allow-unauthenticated \
--entry-point get_list \
--entry-point {{ fn }} \
--source . \
--set-env-vars GCP_PROJECT_ID=your-project-id,FIRESTORE_COLLECTION={{ project_slug }}
--set-env-vars GCP_PROJECT_ID=your-project-id,FIRESTORE_COLLECTION_{{ resource.container | upper | replace('-', '_') }}={{ resource.container }}
{%- endfor %}
{%- endfor %}
```
{%- endif %}
{% if cloud_service == 'AWS Lambda' -%}
Expand Down
2 changes: 1 addition & 1 deletion python/template/blueprints/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% if cloud_service == 'Azure Function App' -%}
from .{{project_slug}}_api import bp
from .api import bp

__all__ = ["bp"]
{%- endif %}
Expand Down
Loading
Loading