-
Notifications
You must be signed in to change notification settings - Fork 1
feat(cli): add --api flavor to autumn new for JSON-first projects #1847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| [package] | ||
| name = "{{project_name}}" | ||
| version = "0.1.0" | ||
| edition = "2024" | ||
|
|
||
| [dependencies] | ||
| # JSON-first API project (`autumn new --api`): the HTML/CSS view stack is | ||
| # disabled via `default-features = false`, leaving a lean JSON feature set. | ||
| # Enable more optional subsystems by extending the features list below, e.g. | ||
| # "mail" or "redis". | ||
| autumn-web = "{{autumn_version}}" | ||
| diesel_migrations = "2" | ||
|
|
||
| [features] | ||
| # Flash messages carry one-shot notices across redirects; kept on for parity | ||
| # with the fullstack scaffold (harmless for pure-JSON apps). | ||
| default = ["flash"] | ||
| flash = ["autumn-web/flash"] | ||
| # Enable single-binary asset embedding (autumn build --embed). Only meaningful | ||
| # once you add embeddable assets (e.g. i18n locale bundles via --with-i18n). | ||
| embed-assets = ["autumn-web/embed-assets"] | ||
|
|
||
| [dev-dependencies] | ||
| # tokio runtime macros are needed for #[tokio::test] in integration tests. | ||
| tokio = { version = "1", features = ["rt", "macros"] } | ||
|
|
||
| [workspace] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| FROM rust:{{rust_version}}-bookworm AS builder | ||
|
|
||
| WORKDIR /app | ||
|
|
||
| COPY Cargo.toml autumn.toml build.rs ./ | ||
| COPY src ./src | ||
| COPY migrations ./migrations | ||
| # __AUTUMN_I18N_BUILDER_COPY__ | ||
|
|
||
| # Git/build provenance passthrough. The build context excludes `/.git` (see | ||
| # .dockerignore), so the generated build.rs cannot shell out to git here. | ||
| # Supply provenance explicitly and surface it as ENV, which build.rs prefers | ||
| # over git and bakes into the binary for `/actuator/info`. Populate from CI, e.g: | ||
| # docker build \ | ||
| # --build-arg AUTUMN_BUILD_GIT_SHA=$(git rev-parse HEAD) \ | ||
| # --build-arg AUTUMN_BUILD_GIT_SHA_SHORT=$(git rev-parse --short HEAD) \ | ||
| # --build-arg AUTUMN_BUILD_GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \ | ||
| # --build-arg AUTUMN_BUILD_GIT_DIRTY=false \ | ||
| # --build-arg AUTUMN_BUILD_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) . | ||
| # Unset args stay empty and are ignored, so `/actuator/info` reports `null`. | ||
| ARG AUTUMN_BUILD_GIT_SHA= | ||
| ARG AUTUMN_BUILD_GIT_SHA_SHORT= | ||
| ARG AUTUMN_BUILD_GIT_BRANCH= | ||
| ARG AUTUMN_BUILD_GIT_DIRTY= | ||
| ARG AUTUMN_BUILD_TIMESTAMP= | ||
| ENV AUTUMN_BUILD_GIT_SHA=${AUTUMN_BUILD_GIT_SHA} \ | ||
| AUTUMN_BUILD_GIT_SHA_SHORT=${AUTUMN_BUILD_GIT_SHA_SHORT} \ | ||
| AUTUMN_BUILD_GIT_BRANCH=${AUTUMN_BUILD_GIT_BRANCH} \ | ||
| AUTUMN_BUILD_GIT_DIRTY=${AUTUMN_BUILD_GIT_DIRTY} \ | ||
| AUTUMN_BUILD_TIMESTAMP=${AUTUMN_BUILD_TIMESTAMP} | ||
|
|
||
| RUN cargo build --release | ||
|
|
||
| FROM debian:bookworm-slim AS runtime | ||
|
|
||
| RUN apt-get update \ | ||
| && apt-get install -y --no-install-recommends ca-certificates \ | ||
| && rm -rf /var/lib/apt/lists/* \ | ||
| && useradd --system --create-home --uid 10001 autumn | ||
|
|
||
| WORKDIR /app | ||
|
|
||
| COPY --from=builder /app/target/release/{{project_name}} /usr/local/bin/{{project_name}} | ||
| COPY --from=builder /app/autumn.toml /app/autumn.toml | ||
| COPY --from=builder /app/migrations /app/migrations | ||
| # __AUTUMN_I18N_RUNTIME_COPY__ | ||
|
|
||
| ENV AUTUMN_PROFILE=prod | ||
| EXPOSE 3000 | ||
|
|
||
| USER autumn | ||
|
|
||
| CMD ["{{project_name}}"] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| fn main() { | ||
| println!("cargo:rerun-if-changed=src/"); | ||
|
|
||
| // Bake build + git provenance into the binary so `/actuator/info` can | ||
| // report exactly which commit/build is running (deploy/rollback checks). | ||
| // The JSON-first API scaffold has no Tailwind/CSS compilation step. | ||
| emit_build_provenance(); | ||
| } | ||
|
|
||
| /// Emit `AUTUMN_BUILD_*` compile-time env vars consumed by | ||
| /// `#[autumn_web::main]` and surfaced on `/actuator/info`. | ||
| /// | ||
| /// Provenance is sourced with a two-tier strategy so both local checkouts and | ||
| /// containerized (production) builds report real values: | ||
| /// | ||
| /// 1. **Build-arg passthrough** — if the corresponding `AUTUMN_BUILD_*` env var | ||
| /// is set (e.g. Docker `--build-arg` surfaced as `ENV`), it wins. The | ||
| /// scaffolded Dockerfile deliberately excludes `/.git` from the build | ||
| /// context, so a container build has no repository to inspect; the deploy/CI | ||
| /// supplies the SHA/branch/etc. explicitly instead. | ||
| /// 2. **Git fallback** — otherwise fields are best-effort from `git`. Outside a | ||
| /// checkout (tarball / CI cache with no passthrough) they are simply omitted | ||
| /// and the framework reports them as `null` rather than failing the build. | ||
| /// | ||
| /// The build timestamp is always emitted (passthrough `AUTUMN_BUILD_TIMESTAMP` | ||
| /// wins, else `SOURCE_DATE_EPOCH` for reproducible builds, else wall clock). | ||
| /// | ||
| /// The `dirty` flag is three-state: an explicit `AUTUMN_BUILD_GIT_DIRTY` build | ||
| /// arg wins; else the real `git status` (a *clean* tree is `false`); else — no | ||
| /// arg and no git — it is genuinely UNKNOWN and no var is emitted, so | ||
| /// `/actuator/info` reports `null` instead of a misleading `false`. | ||
| /// | ||
| /// Note: the git-derived `dirty` flag is a best-effort snapshot taken when this | ||
| /// script last re-ran. Cargo only re-runs it on the declared `rerun-if-changed` | ||
| /// inputs, so after an edit that doesn't touch a watched path the baked flag may | ||
| /// lag until the next rebuild of a watched input. | ||
| fn emit_build_provenance() { | ||
| // Re-run whenever a passthrough build arg changes so a new deploy re-bakes | ||
| // the provenance instead of reusing a cached, stale value. | ||
| for var in [ | ||
| "AUTUMN_BUILD_GIT_SHA", | ||
| "AUTUMN_BUILD_GIT_SHA_SHORT", | ||
| "AUTUMN_BUILD_GIT_BRANCH", | ||
| "AUTUMN_BUILD_GIT_DIRTY", | ||
| "AUTUMN_BUILD_TIMESTAMP", | ||
| ] { | ||
| println!("cargo:rerun-if-env-changed={var}"); | ||
| } | ||
|
|
||
| let timestamp = build_arg("AUTUMN_BUILD_TIMESTAMP").unwrap_or_else(build_timestamp); | ||
| println!("cargo:rustc-env=AUTUMN_BUILD_TIMESTAMP={timestamp}"); | ||
|
|
||
| // Prefer the passthrough SHA (container build); fall back to git (local | ||
| // checkout). If neither yields a SHA, emit no git fields → reported `null`. | ||
| if let Some(sha) = build_arg("AUTUMN_BUILD_GIT_SHA").or_else(|| git(&["rev-parse", "HEAD"])) { | ||
| let short = build_arg("AUTUMN_BUILD_GIT_SHA_SHORT") | ||
| .or_else(|| git(&["rev-parse", "--short", "HEAD"])) | ||
| .unwrap_or_else(|| sha.chars().take(7).collect()); | ||
| let branch = build_arg("AUTUMN_BUILD_GIT_BRANCH") | ||
| .or_else(|| git(&["rev-parse", "--abbrev-ref", "HEAD"])) | ||
| .unwrap_or_else(|| "unknown".into()); | ||
| // Dirty is a THREE-state value: an explicit passthrough arg wins; | ||
| // otherwise the real `git status` result (a *clean* tree is `false`, not | ||
| // unknown); otherwise — no arg AND no git, the common case for a | ||
| // container build with `/.git` excluded — the dirty state is genuinely | ||
| // UNKNOWN. In that case emit no `AUTUMN_BUILD_GIT_DIRTY` at all, so | ||
| // `/actuator/info` reports `null` rather than a misleading `false`. | ||
| let dirty = | ||
| build_arg("AUTUMN_BUILD_GIT_DIRTY").or_else(|| git_dirty().map(|d| d.to_string())); | ||
| println!("cargo:rustc-env=AUTUMN_BUILD_GIT_SHA={sha}"); | ||
| println!("cargo:rustc-env=AUTUMN_BUILD_GIT_SHA_SHORT={short}"); | ||
| println!("cargo:rustc-env=AUTUMN_BUILD_GIT_BRANCH={branch}"); | ||
| if let Some(dirty) = dirty { | ||
| println!("cargo:rustc-env=AUTUMN_BUILD_GIT_DIRTY={dirty}"); | ||
| } | ||
| } | ||
|
|
||
| // Re-run when the ref is checked out (HEAD), the index changes (staging / | ||
| // index), or HEAD *moves* — commit, `--amend`, `reset --soft` all rewrite | ||
| // `logs/HEAD` even when `HEAD` itself is untouched. | ||
| emit_git_rerun_triggers(); | ||
|
|
||
| // Reproducible builds: re-run if the source date is pinned or changed. | ||
| println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); | ||
| } | ||
|
|
||
| /// Read a provenance build arg passed through the environment (Docker | ||
| /// `ARG`/`ENV`), trimming surrounding whitespace. Unset or blank values return | ||
| /// `None` so detection falls through to git — an unpopulated `ARG` surfaces as | ||
| /// an empty string, which must not shadow the git fallback. | ||
| fn build_arg(name: &str) -> Option<String> { | ||
| let value = std::env::var(name).ok()?; | ||
| let trimmed = value.trim(); | ||
| if trimmed.is_empty() { | ||
| None | ||
| } else { | ||
| Some(trimmed.to_string()) | ||
| } | ||
| } | ||
|
|
||
| /// Register `rerun-if-changed` triggers on the git ref state so a commit / | ||
| /// amend / reset re-runs this script and re-bakes the SHA and dirty flag. | ||
| /// | ||
| /// The gitdir is resolved by asking git itself (`git rev-parse --git-dir`) | ||
| /// rather than looking for a `.git` entry in the package root. That matters for | ||
| /// apps generated *inside* an existing repo/monorepo: the package root has no | ||
| /// `.git`, but `git` still resolves the parent checkout — so we must register | ||
| /// the parent's `HEAD`/`logs/HEAD`/`index`, or Cargo would never re-run after a | ||
| /// commit and `/actuator/info` would report a stale SHA. | ||
| /// | ||
| /// `--git-common-dir` covers linked worktrees, where per-worktree state lives | ||
| /// in `--git-dir` but shared history (`logs/HEAD`) lives in the common dir. In | ||
| /// a linked worktree a `commit --amend`/`reset` rewrites the *per-worktree* | ||
| /// reflog (`<git-dir>/logs/HEAD`) while leaving the common-dir reflog untouched, | ||
| /// so both reflogs are watched (deduped for a normal, non-worktree checkout). | ||
| /// | ||
| /// Graceful degradation: if git is absent (tarball / CI cache), `git rev-parse` | ||
| /// fails, we register no triggers, and the build still succeeds. | ||
| fn emit_git_rerun_triggers() { | ||
| // git prints paths relative to the current dir; resolve them against the | ||
| // crate dir so they are valid regardless of Cargo's working directory. | ||
| let crate_dir = std::env::var_os("CARGO_MANIFEST_DIR") | ||
| .map(std::path::PathBuf::from) | ||
| .unwrap_or_default(); | ||
| let resolve = |flag: &str| -> Option<std::path::PathBuf> { | ||
| let raw = git(&["rev-parse", flag])?; | ||
| let path = std::path::PathBuf::from(&raw); | ||
| Some(if path.is_absolute() { | ||
| path | ||
| } else { | ||
| crate_dir.join(path) | ||
| }) | ||
| }; | ||
|
|
||
| let Some(git_dir) = resolve("--git-dir") else { | ||
| return; | ||
| }; | ||
| // Linked worktrees keep shared `logs/HEAD` in the common dir; when there is | ||
| // no separate common dir this is the same as `git_dir`. | ||
| let common_dir = resolve("--git-common-dir").unwrap_or_else(|| git_dir.clone()); | ||
|
|
||
| let mut seen: Vec<std::path::PathBuf> = Vec::new(); | ||
| for path in [ | ||
| git_dir.join("HEAD"), | ||
| git_dir.join("index"), | ||
| // Per-worktree reflog: `--amend`/`reset` in a linked worktree rewrites | ||
| // `<git-dir>/logs/HEAD` but leaves the shared common-dir reflog | ||
| // untouched, so watch both. Deduped when git_dir == common_dir. | ||
| git_dir.join("logs/HEAD"), | ||
| common_dir.join("logs/HEAD"), | ||
| ] { | ||
| if seen.contains(&path) { | ||
| continue; // dedupe when git_dir == common_dir | ||
| } | ||
| if path.exists() { | ||
| println!("cargo:rerun-if-changed={}", path.display()); | ||
| } | ||
| seen.push(path); | ||
| } | ||
| } | ||
|
|
||
| /// Best-effort working-tree dirtiness, distinguishing *clean* from *unknown*. | ||
| /// | ||
| /// Returns `Some(true)`/`Some(false)` only when `git status` actually ran — a | ||
| /// clean tree is `Some(false)`, uncommitted changes are `Some(true)`. Returns | ||
| /// `None` when git is absent or the command fails (e.g. a container build with | ||
| /// `/.git` excluded), so the caller can represent an *unknown* dirty state as | ||
| /// absent (`/actuator/info` → `null`) rather than a misleading `false`. | ||
| /// | ||
| /// This cannot reuse `git()`, which collapses empty stdout to `None` and would | ||
| /// make a clean tree indistinguishable from git being unavailable. | ||
| fn git_dirty() -> Option<bool> { | ||
| let output = std::process::Command::new("git") | ||
| .args(["status", "--porcelain"]) | ||
| .output() | ||
| .ok()?; | ||
| if !output.status.success() { | ||
| return None; | ||
| } | ||
| let text = String::from_utf8(output.stdout).ok()?; | ||
| Some(!text.trim().is_empty()) | ||
| } | ||
|
|
||
| /// Run a git subcommand, returning trimmed stdout on success. | ||
| fn git(args: &[&str]) -> Option<String> { | ||
| let output = std::process::Command::new("git").args(args).output().ok()?; | ||
| if !output.status.success() { | ||
| return None; | ||
| } | ||
| let text = String::from_utf8(output.stdout).ok()?.trim().to_string(); | ||
| if text.is_empty() { None } else { Some(text) } | ||
| } | ||
|
|
||
| /// Build timestamp as ISO-8601 UTC. Honors `SOURCE_DATE_EPOCH` (a Unix seconds | ||
| /// count) when set so reproducible builds get a deterministic timestamp; | ||
| /// otherwise falls back to the current wall-clock time. | ||
| fn build_timestamp() -> String { | ||
| let secs = std::env::var("SOURCE_DATE_EPOCH") | ||
| .ok() | ||
| .and_then(|value| value.trim().parse::<u64>().ok()) | ||
| .unwrap_or_else(unix_now_secs); | ||
| iso8601_utc(secs) | ||
| } | ||
|
|
||
| /// Current time as whole Unix seconds (0 if the clock predates the epoch). | ||
| fn unix_now_secs() -> u64 { | ||
| std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .map(|d| d.as_secs()) | ||
| .unwrap_or(0) | ||
| } | ||
|
|
||
| /// Format Unix `secs` as ISO-8601 UTC (`YYYY-MM-DDTHH:MM:SSZ`), computed with | ||
| /// no external crates via the civil-from-days algorithm. | ||
| fn iso8601_utc(secs: u64) -> String { | ||
| let days = (secs / 86_400) as i64; | ||
| let rem = secs % 86_400; | ||
| let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60); | ||
|
|
||
| // Howard Hinnant's civil_from_days, epoch 1970-01-01. | ||
| let z = days + 719_468; | ||
| let era = if z >= 0 { z } else { z - 146_096 } / 146_097; | ||
| let doe = z - era * 146_097; // [0, 146096] | ||
| let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] | ||
| let year = yoe + era * 400; | ||
| let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] | ||
| let mp = (5 * doy + 2) / 153; // [0, 11] | ||
| let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31] | ||
| let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] | ||
| let year = if month <= 2 { year + 1 } else { year }; | ||
|
|
||
| format!("{year:04}-{month:02}-{day:02}T{hh:02}:{mm:02}:{ss:02}Z") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| use autumn_web::migrate::{EmbeddedMigrations, embed_migrations}; | ||
| use autumn_web::prelude::*; | ||
| use autumn_web::reexports::serde_json; | ||
|
|
||
| const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); | ||
|
|
||
| // JSON-first API scaffold (`autumn new --api`): handlers return `Json<...>`, | ||
| // there is no HTML/CSS view layer. Add typed request/response structs with | ||
| // `#[derive(serde::Serialize)]` (via `autumn_web::reexports::serde`) as your | ||
| // API grows. | ||
|
|
||
| #[get("/")] | ||
| async fn index() -> Json<serde_json::Value> { | ||
| Json(serde_json::json!({ | ||
| "name": "{{project_name}}", | ||
| "message": "Welcome to {{project_name}}!", | ||
| })) | ||
| } | ||
|
|
||
| #[get("/hello/{name}")] | ||
| async fn hello_name(name: autumn_web::extract::Path<String>) -> Json<serde_json::Value> { | ||
| Json(serde_json::json!({ | ||
| "message": format!("Hello, {}!", *name), | ||
| })) | ||
| } | ||
|
|
||
| #[autumn_web::main] | ||
| async fn main() { | ||
| let app = autumn_web::app() | ||
| .routes(routes![index, hello_name]) | ||
| .migrations(MIGRATIONS); | ||
|
|
||
| app | ||
| .run() | ||
| .await; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a project is scaffolded with
autumn new <app> --api --with-i18n,inject_i18n_apimakessrc/main.rscall.i18n_auto(), and this Dockerfile builds without theembed-assetsfeature, so the app falls back to loading/app/i18n/en.ftlat startup. This copy block never carries thei18n/sidecar into the runtime image (and the builder stage does not copy it either), so that generated Docker image panics on startup with the default locale missing.Useful? React with 👍 / 👎.