glacier-web-cli 0.5.0

Command-line tool for creating and running Glacier applications.
# Glacier's framework APIs

What `.glacier` code calls beyond its own forms. In `.glacier`, `Type.function(…)` is
`Type::function(…)` and `!` awaits a call and passes its error up. Everything below is in
`glacier::prelude` unless a path is given.

## The app (`src/app.glacier`)

- `app do … end`: settings a line each, `middleware X`, `jobs expr`,
  `state expr`, `mail_previews expr`, `billing`, `webhooks`,
  `health_check "/up"`, `max_body_size n`; `config` in scope; `def`s after
- Added automatically: config, database, `db/migrations/`,
  `db/<name>/migrations/`, `src/billing.glacier`, `src/webhooks.glacier`,
  `jobs::all()`, seeds, `public/`, routes
- `jobs do keep_failed_for 30.days(); queue :mailers, workers: 2 end` in
  `app do`; a job's own `cron "…"` / `every 1.hours()`; a model's `prune do
  <query> end`; a mailer's `preview :name do … end`
- `Model.where_sql("sql ?", [values])`
- `src/lib.rs` = `glacier::app!();`, `src/main.rs` = `glacier::serve!();`,
  `tests/glacier.rs` = `glacier::tests!();`

## Requests (in an action)

- `params`, `params[:id]` (text, `""` if missing), `params.require("post")?`, `params.fetch("key")?`,
  `params.without("user_id")`, `params.get("key")` (`Option<&str>`)
- `session.get::<T>("key")`, `session.insert("key", value)?`, `session.remove("key")`
- `file("photo")` (`Option<Upload>`), `files` (every upload)
- `ctx.id()?` (the `:id` as a number), `ctx.param_as::<T>("name")?`, `ctx.header("name")`,
  `ctx.json::<T>()?`, `ctx.query::<T>()?`, `ctx.method()`, `ctx.path()`, `ctx.remote_ip()`
- `ctx.put_flash("notice", "…")`, `ctx.state::<T>()?` (app state), `ctx.extensions()`

## Responses

- `render post, title: "…", status: 422`, `render :new, post`, `redirect post, notice: "…"`,
  `json post, serializer: PostSerializer, status: :created`, `head :no_content`
- `Response.download(bytes, "name.pdf")`, `Response.inline(bytes, "name.pdf")`,
  `Response.redirect("/path")`, `response.with_header("name", "value")`
- `Error.not_found()`, `Error.forbidden()`, `Error.bad_request("…")`, `Error.unprocessable("…")`,
  `Error.internal("…")`, `Error.new(StatusCode::CONFLICT, "…")`

## Models and queries

- `Post.find(id)!` (404 if missing; takes `params[:id]`), `Post.find_by(title: "x")!` (`Option`),
  `Post.where(published: true).order(inserted_at: :desc).limit(10)!`, `Post.all()!`, `Post.count()!`
- `Post.build(params)`, `post.save()!` (`bool`: false with errors), `post.update(params)!`,
  `post.destroy()!` (callbacks, `dependent`, attachments), `post.delete()!` (none)
- `post.errors_json()`, `post.full_messages()`, `post.errors_on("title")`, `post.errors()`
- `.preload(:comments)`, `post.comments.is_loaded()`, `post.author.get()`
- `glacier::db::sql("SELECT … WHERE id = ?").bind(id).fetch_all()!` (rows: `row.get::<T>("col")?`),
  `.execute()!`; `transaction do … end`
- `cursor`, `paginate`: `Post.order(:id).cursor(CursorPage.from_params(&params, 20, 100)?)!`

## Time, config, storage

- `glacier::time::now()` (`Timestamp`, UTC), `1.days().ago()`, `2.hours().from_now()`,
  `NaiveTimestamp.now()`; tests: `travel(1.days())`, `travel_to(t)`
- `Config.current()?` (the app's config anywhere), `config.secret("stripe.key", "STRIPE_KEY")?`,
  `config.get_opt::<T>("section.key")?`
- `glacier::storage::current()` (the `[storage]` store): `.put(key, bytes, type)!`, `.get(key)!`,
  `.delete(key)!`, `.url(key, seconds)`
- `glacier::cache::fetch("key", 1.hours(), fn -> … end)!`, `cache::key(&post, "html")`

## Jobs, mail, clients, webhooks

- A job: `SendReport { report_id: 7 }.enqueue()!`, `.enqueue_in(1.hours())!`
- Mail: `ReportMailer.ready(&report).deliver_later()!` (in a job) or `.deliver_now()!`
- A client: `Stripe.new()?.prices()!` (from the app's config), `Stripe.from_config(&config)?`;
  inside its `def`s: `get("path").fetch()!` (JSON), `.fetch_text()!`, `post("path").json(&body)`
- Webhooks: `event.payload()` (JSON), `event.parse::<T>()?`, `event.once("name", effect)!`
- Billing: `glacier::billing::checkout(…)!`; hooks get `Synced<Charge>` / `Synced<Subscription>`

## Serializers and JSON

- `serializer PostSerializer do attributes :id, :title … end`; `PostSerializer.many(&posts)`
- `glacier::serialize::Object`: build JSON by hand: `obj = Object.new()`, then `obj.attr("key", &value)`
- `JsonValue` (`serde_json::Value`), `json!({"a": 1})`

## Tests

- `get "/posts"`, `post "/posts", [title: "x"]`, `post_json "/api", body`, `patch post, […]`
- `res.status == :ok`, `res.body =~ "text"`, `res.location`, `res.json()`, `res.header("name")`
- `insert(Post)`, `insert(Post, title: "x")`, `params_for(Post)`, factories in `tests/factories/`; `insert_for(access, Post)` fills the owner column from an Access
- `stub_request("GET", "https://api…/*", 200, json!({…}))`, `requests()`,
  `delivered_mail()`, `enqueued_jobs()`, `perform_jobs()!`, `test_config()`

## Security

- `authorize PostPolicy.update(user, post)`, `skip_authorization`, `VerifyAuthorized`
- `glacier audit`, `glacier precommit`