Iwf Guide

Iwf Guide

First CRUD Feature

This guide builds one small article feature in the order users usually need it: page, route, form, table, query, validation, auth, live refresh, and final check. It uses the app-facing Iwf surface only.

1. Create The App

Start from a generated project:

iwf new my-app
cd my-app
iwf build

Keep the starter shape:

module Main

import Iwf

%language ElabReflection
%default partial

The normal app path starts from import Iwf. Add feature imports only when the feature needs generated app code, such as Generated.Schema for database rows.

2. Add A Page

Write the page directly. The view returns ordinary .idrx HTML; render adds the default shell for full browser requests:

articleIndexView : Html
articleIndexView =
  fragment
    [ <title>Articles</title>
    , <main id="main-content" class="mx-auto flex max-w-3xl flex-col gap-6 p-6">
        <header class="flex items-center justify-between">
          <h1>Articles</h1>
          <a class="btn-primary" href="/articles/new">New article</a>
        </header>
        <p>No articles yet.</p>
      </main>
    ]

render serves the full shell for normal browser requests and the #main-content fragment for boosted requests.

3. Add A Route With A Path Parameter

Path captures live in a normal record:

record ArticlePath where
  constructor MkArticlePath
  slug : String

The route spec tells Iwf which path fields to decode, and handledBy checks the handler implements that spec:

showArticle : ArticlePath -> ControllerContext -> ControllerApp Response
showArticle input context = do
  maybeArticle <-
    sqlQuery
      "select articles.* from articles where slug = ${input.slug}"
  article <- requireFound maybeArticle
  pure (render context (articleShowView article))

showArticleRoute : RouteSpec ArticlePath NoInput Response
showArticleRoute =
  get "/articles/{slug}" |> params ArticlePath |> html

showArticleBinding : BoundRoute
showArticleBinding =
  handledBy showArticleRoute showArticle

There is no separate body declaration for this route because the route has no body. For routes that do have a body, declare the body type on the route spec and bind the handler with handledBy.

4. Add A Form

Define the submitted fields:

record ArticleForm where
  constructor MkArticleForm
  title : String
  slug : String
  body : String

%runElab derive "ArticleForm" [Show, Eq, FormFields]

Write the form as direct HTML:

newArticleForm : ArticleForm -> List ValidationError -> Html
newArticleForm input errors =
  <form id="article-form" class="form stack" method="post" action={pathTo createArticleRoute}>
    {errorSummary errors}
    <label class="field">
      <span>Title</span>
      <input name={articleFormTitleField} value={input.title} />
    </label>
    <label class="field">
      <span>Slug</span>
      <input name={articleFormSlugField} value={input.slug} />
    </label>
    <label class="field">
      <span>Body</span>
      <textarea name={articleFormBodyField}>{input.body}</textarea>
    </label>
    <button class="btn-primary" type="submit">Publish</button>
  </form>

The route value supplies the action URL. The generated field values supply the name attributes. If a field is renamed on ArticleForm, stale form markup fails at compile time.

Put the form in a page and route to it:

newArticlePage : ArticleForm -> List ValidationError -> Html
newArticlePage input errors =
  fragment
    [ <title>New Article</title>
    , <main id="main-content" class="mx-auto flex max-w-3xl flex-col gap-6 p-6">
        <h1>New article</h1>
        {newArticleForm input errors}
      </main>
    ]

newArticle : ControllerContext -> Response
newArticle context =
  render context (newArticlePage (MkArticleForm "" "" "") [])

newArticleRoute : RouteSpec NoInput NoInput Response
newArticleRoute =
  get "/articles/new"

newArticleBinding : BoundRoute
newArticleBinding =
  handledBy newArticleRoute newArticle

5. Add A Table

Add the table to Application/Schema.sql:

CREATE TABLE articles (
  id BIGSERIAL PRIMARY KEY,
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

Then rebuild:

iwf build

The build generates Generated.Schema, including Article and typed table ids. Import it where handlers or views use generated models:

import Generated.Schema

6. Add TypedSQL Queries

Write the SQL directly beside the handler code. sqlQuery is for statements that return rows; sqlExec is for statements that do not return rows.

Replace the empty page with a data-driven page:

articleIndexView : ControllerContext -> List Article -> Html
articleIndexView context articles =
  fragment
    [ <title>Articles</title>
    , <main id="main-content" class="mx-auto flex max-w-3xl flex-col gap-6 p-6">
        <header class="flex items-center justify-between">
          <h1>Articles</h1>
          <a class="btn-primary" href="/articles/new">New article</a>
        </header>
        {articleList articles}
      </main>
    ]

Run queries directly from the controller. The handler's ControllerContext provides the current database automatically:

articles : ControllerContext -> ControllerApp Response
articles context = do
  rows <-
    sqlQuery
      "select articles.* from articles order by created_at desc"
  pure (render context (articleIndexView context rows))

For writes, use sqlExec:

insertArticle : ArticleForm -> ControllerContext -> ControllerApp ()
insertArticle input context =
  sqlExec
    "insert into articles (slug, title, body) values (${input.slug}, ${input.title}, ${input.body})"

7. Add Validation

Validate the decoded body record before writing:

validateArticle : ArticleForm -> Either (List ValidationError) ArticleForm
validateArticle input =
  allValidations
    [ mapValidation (\_ => input) (requiredString "title" input.title)
    , mapValidation (\_ => input) (requiredString "slug" input.slug)
    , mapValidation (\_ => input) (requiredString "body" input.body)
    ]
    (valid input)

Use the same page when validation fails:

createArticle : ArticleForm -> ControllerContext -> ControllerApp Response
createArticle input context =
  case validateSessionCsrf context.session context.request of
    Left errors => pure (render context (newArticlePage input errors))
    Right _ =>
      case validateArticle input of
        Left errors => pure (render context (newArticlePage input errors))
        Right valid => do
          insertArticle valid context
          pure (seeOther (pathTo showArticleRoute (MkArticlePath valid.slug)))

createArticleRoute : RouteSpec NoInput ArticleForm Response
createArticleRoute =
  post "/articles" |> body ArticleForm |> html

createArticleBinding : BoundRoute
createArticleBinding =
  handledBy createArticleRoute createArticle

The route declaration records the submitted body type, and handledBy checks that the controller implements ArticleForm -> ControllerContext -> ControllerApp Response.

8. Add Auth

Protect only the write path at first:

createArticle : ArticleForm -> ControllerContext -> ControllerApp Response
createArticle input context = do
  _ <- requireCurrentUser context
  case validateSessionCsrf context.session context.request of
    Left errors => pure (render context (newArticlePage input errors))
    Right _ =>
      case validateArticle input of
        Left errors => pure (render context (newArticlePage input errors))
        Right valid => do
          insertArticle valid context
          pure (seeOther (pathTo showArticleRoute (MkArticlePath valid.slug)))

Keep the auth decision in the handler. The route table remains a readable list of HTTP method, path, and handler.

9. Add A Live Fragment

Mark the list as depending on the articles table:

articleFeed : ControllerContext -> List Article -> Html
articleFeed context articles =
  liveFragment context "article-feed" "/articles/partial" ["articles"]
    (articleList articles)

Use it in the full document:

articleIndexView : ControllerContext -> List Article -> Html
articleIndexView context articles =
  fragment
    [ <title>Articles</title>
    , <main id="main-content">
        {articleFeed context articles}
      </main>
    ]

Return the same region from a fragment endpoint:

articlesPartial : ControllerContext -> ControllerApp Response
articlesPartial context = do
  rows <-
    sqlQuery
      "select articles.* from articles order by created_at desc"
  pure (renderFragment context (articleFeed context rows))

articlesPartialRoute : RouteSpec NoInput NoInput Response
articlesPartialRoute =
  get "/articles/partial"

articlesPartialBinding : BoundRoute
articlesPartialBinding =
  handledBy articlesPartialRoute articlesPartial

Enable Auto Refresh once in the app runner:

main : IO ()
main =
  runApp
    (app "My App" appRoutes
      |> withDatabaseFromEnv
      |> withSessionSecretFromEnv
      |> withAutoRefresh)

The page says which table it depends on. Iwf handles the live refresh wiring and refresh request.

10. Ship With Check

Keep the route table readable:

export
htmlArticlesRoute : RouteSpec NoInput NoInput Response
htmlArticlesRoute =
  get "/articles"

htmlArticlesBinding : BoundRoute
htmlArticlesBinding =
  handledBy htmlArticlesRoute articles

export
htmlNewArticleRoute : RouteSpec NoInput NoInput Response
htmlNewArticleRoute =
  get "/articles/new"

htmlNewArticleBinding : BoundRoute
htmlNewArticleBinding =
  handledBy htmlNewArticleRoute newArticle

export
htmlCreateArticleRoute : RouteSpec NoInput ArticleForm Response
htmlCreateArticleRoute =
  post "/articles" |> body ArticleForm |> html

htmlCreateArticleBinding : BoundRoute
htmlCreateArticleBinding =
  handledBy htmlCreateArticleRoute createArticle

export
htmlArticleRoute : RouteSpec ArticlePath NoInput Response
htmlArticleRoute =
  get "/articles/{slug}" |> params ArticlePath |> html

htmlArticleBinding : BoundRoute
htmlArticleBinding =
  handledBy htmlArticleRoute showArticle

export
htmlArticlesPartialRoute : RouteSpec NoInput NoInput Response
htmlArticlesPartialRoute =
  get "/articles/partial"

htmlArticlesPartialBinding : BoundRoute
htmlArticlesPartialBinding =
  handledBy htmlArticlesPartialRoute articlesPartial

export
htmlRoutes : Routes
htmlRoutes =
  [ htmlArticlesBinding
  , htmlNewArticleBinding
  , htmlCreateArticleBinding
  , htmlArticleBinding
  , htmlArticlesPartialBinding
  ]

appRoutes : Routes
appRoutes =
  htmlRoutes

Run the app check before shipping:

iwf check

At this point the feature has direct page HTML, direct forms, declared route records, checked SQL, validation, auth, and one live fragment without leaving the app-facing interface.

Next

Read Architecture when you want to split the single-file starter into Application/, Config/, and Web/ modules.