Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
67fe83a
Add OpenAPI support for AutoRoute JSON views
vincentcombey-design Mar 20, 2026
65cd110
Fix OpenAPI review findings
vincentcombey-design Mar 20, 2026
27c224c
Add Swagger UI OpenAPI routes
vincentcombey-design Mar 20, 2026
fb77e2a
Add openapi3 to ihp Nix derivation
vincentcombey-design Mar 20, 2026
0e8d557
Add regression test for Api action names
vincentcombey-design Mar 20, 2026
f881ab9
Add OpenAPI request body docs
vincentcombey-design Apr 7, 2026
0b0b6bf
Export request body docs for OpenAPI
vincentcombey-design Apr 7, 2026
77389c8
Fix OpenAPI test imports
vincentcombey-design Apr 7, 2026
a118f67
Remove redundant OpenAPI test import
vincentcombey-design Apr 7, 2026
93110cc
Tie OpenAPI request bodies to actions
vincentcombey-design Apr 7, 2026
d807ab0
Simplify action request body docs
vincentcombey-design Apr 7, 2026
00649fe
Remove loose OpenAPI request body helper
vincentcombey-design Apr 7, 2026
2e3c0e8
Tighten typed action request body decode
vincentcombey-design Apr 7, 2026
c97baf8
Decode request bodies by registered type
vincentcombey-design Apr 7, 2026
7e81813
Store request body type reps concretely
vincentcombey-design Apr 7, 2026
2a01d93
Add OpenAPI action doc setters
vincentcombey-design Apr 23, 2026
52bd62e
Fix JsonView OpenAPI docs after rebase
vincentcombey-design Apr 24, 2026
5e10096
Add typed OpenAPI action definitions
vincentcombey-design Apr 24, 2026
2088e9e
Make OpenAPI endpoint definitions authoritative
vincentcombey-design Apr 24, 2026
d738674
Remove ActionDefinition runner boilerplate
vincentcombey-design Apr 24, 2026
7b75149
Document supported custom OpenAPI routes
vincentcombey-design Apr 24, 2026
2546e21
Infer OpenAPI request bodies from handlers
vincentcombey-design Apr 24, 2026
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
4 changes: 3 additions & 1 deletion Guide/json-api.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,9 @@ instance View ShowView where
|]

instance JsonView ShowView where
json ShowView { post } = toJSON post
type JsonResponse ShowView = Post

jsonTyped ShowView { post } = post
```

When a browser requests the page (sending `Accept: text/html`), it gets the HTML response. When an API client requests it with `Accept: application/json`, it gets JSON. You can test this:
Expand Down
56 changes: 56 additions & 0 deletions Guide/routing.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,60 @@ instance FrontController WebApplication where

Now you can open e.g. `/Posts` to access the `PostsAction`.

If you want a pure `AutoRoute` controller to also appear in the generated OpenAPI document, define its actions with the inspectable `endpoint` DSL and mount it using [`documentRoute`](https://ihp.digitallyinduced.com/api-docs/IHP-RouterSupport.html#v:documentRoute):

```haskell
instance FrontController WebApplication where
controllers =
[ -- ...
, documentRoute @PostsController
]
```

`documentRoute` derives paths, methods and parameters from `AutoRoute`. Response schemas, request bodies and operation metadata come from the `endpoint` definitions next to the controller handlers.

Simple `customRoutes` / `customPathTo` overrides are supported when `customPathTo` returns a path backed by `customRoutes` and all action fields appear in the path. If IHP cannot prove this during OpenAPI generation, `buildOpenApi` fails with an `OpenApiGenerationException` instead of silently generating stale docs. Lower-level parser routes stay undocumented.

You can then build the OpenAPI document from the mounted router tree:

```haskell
spec :: Value
spec = buildOpenApi RootApplication
```

[`buildOpenApi`](https://ihp.digitallyinduced.com/api-docs/IHP-OpenApiSupport.html#v:buildOpenApi) traverses the same front controller structure that serves requests, so nested `mountFrontController` prefixes are reflected in the generated paths.

If you also want to serve the generated specification and a Swagger UI for it, mount [`swaggerUi`](https://ihp.digitallyinduced.com/api-docs/IHP-OpenApiSupport.html#v:swaggerUi) in the same front controller:

```haskell
instance FrontController WebApplication where
controllers =
[ documentRoute @PostsController
, swaggerUi
]
```

This serves:

- `/api-docs` with the Swagger UI
- `/api-docs/openapi.json` with the generated OpenAPI 3 document

If you want a different path or page title, use [`swaggerUiWithOptions`](https://ihp.digitallyinduced.com/api-docs/IHP-OpenApiSupport.html#v:swaggerUiWithOptions):

```haskell
instance FrontController WebApplication where
controllers =
[ documentRoute @PostsController
, swaggerUiWithOptions
((defaultSwaggerUiOptions @WebApplication)
{ swaggerUiPath = "/docs"
, swaggerUiTitle = Just "Posts API Docs"
})
]
```

The Swagger UI route stays tied to the same front controller where you mount it, so the UI and the JSON specification are generated from the actual Haskell routes in that router. If you want to document your full root application including outer `mountFrontController` prefixes, mount `swaggerUi` in the root front controller. By default the HTML shell loads the Swagger UI assets from the `swagger-ui-dist` CDN; if you need different asset URLs you can override them in `SwaggerUiOptions`.

## Changing the Start Page / Home Page

You can define a custom start page action using the [`startPage`](https://ihp.digitallyinduced.com/api-docs/IHP-RouterSupport.html#v:startPage) function like this:
Expand Down Expand Up @@ -208,6 +262,8 @@ With this setup:

The `customRoutes` parser is tried first, before the auto-generated routes. If it doesn't match, the auto-generated routes are tried as usual. Return `Nothing` from `customPathTo` for any action that should use the default URL generation.

When mounted with `documentRoute`, this basic custom route is included in the OpenAPI document as `/posts/{postId}`. IHP verifies that the generated `customPathTo` path is accepted by `customRoutes`; unsupported custom paths fail OpenAPI generation with a clear error.

## Custom Routing

Sometimes you have special needs for your routing. For this case, IHP provides a lower-level routing API on which [`AutoRoute`](https://ihp.digitallyinduced.com/api-docs/IHP-RouterSupport.html#t:AutoRoute) is built.
Expand Down
149 changes: 130 additions & 19 deletions Guide/view.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ document.addEventListener('ihp:unload', () => {

## JSON

Views that are rendered by calling the [`render`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Render.html#v:render) function can also respond with JSON.
Views that are rendered by calling the [`renderHtmlOrJson`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Render.html#v:renderHtmlOrJson) function can respond with either HTML or JSON based on the request `Accept` header.

Let's say we have a normal HTML view that renders all posts for our blog app:

Expand Down Expand Up @@ -491,41 +491,74 @@ instance View IndexView where
|]
```

We can add a JSON output for all blog posts by adding a [`json`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewSupport.html#v:json) function to this:
We can add a JSON output for all blog posts by defining a typed [`JsonResponse`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewSupport.html#t:JsonView) payload and implementing [`jsonTyped`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewSupport.html#v:jsonTyped) in a `JsonView` instance:

```haskell
import Data.Aeson -- <--- Add this import at the top of the file
{-# LANGUAGE DeriveGeneric #-}

import Data.Aeson
import GHC.Generics (Generic)

data PostPayload = PostPayload
{ id :: !(Id Post)
, title :: !Text
, body :: !Text
}
deriving (Eq, Show, Generic)

instance ToJSON PostPayload
```

```haskell
instance View IndexView where
html IndexView { .. } = [hsx|
...
|]

json IndexView { .. } = toJSON posts -- <---- The new json render function
instance JsonView IndexView where
type JsonResponse IndexView = [PostPayload]

jsonTyped IndexView { .. } =
posts
|> map (\post -> PostPayload
{ id = post.id
, title = post.title
, body = post.body
})
```

In the above code, our [`json`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewSupport.html#v:json) function has access to all arguments passed to the view. Here we call [`toJSON`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewPrelude.html#v:toJSON), which is provided by the [aeson](https://hackage.haskell.org/package/aeson) Haskell library. This simply encodes all the `posts` given to this view as JSON.
In the above code, [`jsonTyped`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewSupport.html#v:jsonTyped) has access to all arguments passed to the view, but returns a normal Haskell value instead of raw `Value`. IHP then turns that into JSON automatically using [`toJSON`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewPrelude.html#v:toJSON).

Additionally we need to define a [`ToJSON`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewPrelude.html#t:ToJSON) instance which describes how the `Post` record is going to be transformed to JSON. We need to add this to our view:
In the controller, use [`renderHtmlOrJson`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Render.html#v:renderHtmlOrJson) instead of `render` for actions that should serve both formats:

```haskell
instance ToJSON Post where
toJSON post = object
[ "id" .= post.id
, "title" .= post.title
, "body" .= post.body
]
action PostsAction = do
posts <- query @Post |> fetch
renderHtmlOrJson IndexView { .. }
```

The full `Index` View for our `PostsController` looks like this:

```haskell
module Web.View.Posts.Index where

{-# LANGUAGE DeriveGeneric #-}

import Web.View.Prelude
import Data.Aeson
import GHC.Generics (Generic)

data IndexView = IndexView { posts :: [Post] }

data PostPayload = PostPayload
{ id :: !(Id Post)
, title :: !Text
, body :: !Text
}
deriving (Eq, Show, Generic)

instance ToJSON PostPayload

instance View IndexView where
html IndexView { .. } = [hsx|
<nav>
Expand All @@ -549,14 +582,16 @@ instance View IndexView where
</div>
|]

json IndexView { .. } = toJSON posts
instance JsonView IndexView where
type JsonResponse IndexView = [PostPayload]

instance ToJSON Post where
toJSON post = object
[ "id" .= post.id
, "title" .= post.title
, "body" .= post.body
]
jsonTyped IndexView { .. } =
posts
|> map (\post -> PostPayload
{ id = post.id
, title = post.title
, body = post.body
})

renderPost post = [hsx|
<tr>
Expand All @@ -568,6 +603,8 @@ renderPost post = [hsx|
|]
```

For dynamic or unstructured JSON responses, keep the escape hatch explicit by using `Value` as the response type and returning it from `jsonTyped`. Documented OpenAPI actions should prefer concrete payload types with matching `ToJSON` and `ToSchema` instances.

### Getting JSON responses

When you open the `PostsAction` at `/Posts` in your browser you will still get the HTML output. [This is because IHP uses the browser `Accept` header to respond in the best format for the browser which is usually HTML.](https://en.wikipedia.org/wiki/Content_negotiation)
Expand All @@ -593,6 +630,80 @@ curl http://localhost:8000/Posts -H 'Accept: application/json'
[{"body":"This is a test json post","id":"d559cd60-e36e-40ef-b69a-d651e3257dc9","title":"Hello World!"}]
```

### OpenAPI schemas for JSON views

If you want an AutoRoute controller action to appear in the generated OpenAPI document, keep the OpenAPI contract next to the handler using inspectable action definitions.
Add a [`ToSchema`](https://ihp.digitallyinduced.com/api-docs/IHP-OpenApiSupport.html#t:ToSchema) instance for the typed JSON payload, then declare the response view and metadata directly in `action`. When the `handle` callback takes a typed argument, IHP decodes that JSON request body and documents the same type in OpenAPI:

```haskell
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeApplications #-}

import GHC.Generics (Generic)

data PostPayload = PostPayload
{ id :: !(Id Post)
, title :: !Text
}
deriving (Eq, Show, Generic)

instance ToJSON PostPayload
instance ToSchema PostPayload

data CreatePostRequest = CreatePostRequest
{ title :: !Text
}
deriving (Eq, Show, Generic)

instance FromJSON CreatePostRequest
instance ToSchema CreatePostRequest

instance View IndexView where
html IndexView { .. } = [hsx|...|]

instance JsonView IndexView where
type JsonResponse IndexView = [PostPayload]
jsonTyped IndexView { .. } = posts |> map (\post -> PostPayload { id = post.id, title = post.title })

instance Controller PostsController where
type ControllerAction PostsController = ActionDefinition PostsController

action PostsAction =
endpoint
|> responseView @IndexView
|> summary "List all posts"
|> handle do
posts <- query @Post |> fetch
pure IndexView { .. }

action CreatePostAction =
endpoint
|> responseView @IndexView
|> summary "Create post"
|> handle \(body :: CreatePostRequest) -> do
_ <- newRecord @Post
|> set #title body.title
|> createRecord
posts <- query @Post |> fetch
pure IndexView { .. }

action HtmlOnlyAction =
legacyAction do
render HtmlOnlyView
```

Mount the controller with [`documentRoute`](https://ihp.digitallyinduced.com/api-docs/IHP-RouterSupport.html#v:documentRoute) to include its inspectable actions in the generated OpenAPI document:

```haskell
instance FrontController WebApplication where
controllers =
[ documentRoute @PostsController
, swaggerUi
]
```

In this form, `handle` decodes the typed request body before the handler runs, and the inferred body type is the type used in the OpenAPI request schema. The handler must return the same view type declared by `responseView`. Actions wrapped in `legacyAction` are executed normally and omitted from the OpenAPI document.

### Advanced: Rendering JSON directly from actions

When you are building an API and your action is only responding with JSON (so no HTML is expected), you can respond with your JSON directly from the controller using [`renderJson`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Render.html#v:renderJson):
Expand Down
2 changes: 2 additions & 0 deletions ihp-ide/IHP/IDE/CodeGen/ApplicationGenerator.hs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ generateGenericApplication applicationName =
<> "instance FrontController " <> applicationName <> "Application where\n"
<> " controllers = \n"
<> " [ startPage WelcomeAction\n"
<> " -- Use documentRoute @YourController instead of parseRoute @YourController\n"
<> " -- when you want OpenAPI docs derived from AutoRoute.\n"
<> " -- Generator Marker\n"
<> " ]\n\n"
<> "instance InitControllerContext " <> applicationName <> "Application where\n"
Expand Down
4 changes: 2 additions & 2 deletions ihp/IHP/AutoRefresh.hs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ autoRefresh runAction = do

case autoRefreshState of
Just (AutoRefreshEnabled {}) -> do
-- When this function calls the 'action ?theAction' in the other case
-- When this function calls the current controller action in the other case
-- we will evaluate this branch
runAction
_ -> do
Expand All @@ -92,7 +92,7 @@ autoRefresh runAction = do
let ?context = controllerContext
let ?request = originalRequest
let ?respond = respond
action ?theAction
runControllerAction (action ?theAction)
) waiRequest waiRespond

-- We save the allowed session ids to the session cookie to only grant a client access
Expand Down
Loading
Loading