diff --git a/Guide/json-api.markdown b/Guide/json-api.markdown index cb58be922..8814f3939 100644 --- a/Guide/json-api.markdown +++ b/Guide/json-api.markdown @@ -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: diff --git a/Guide/routing.markdown b/Guide/routing.markdown index e4128f4ad..0bc4af9fe 100644 --- a/Guide/routing.markdown +++ b/Guide/routing.markdown @@ -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: @@ -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. diff --git a/Guide/view.markdown b/Guide/view.markdown index 21b56059a..3267f73f3 100644 --- a/Guide/view.markdown +++ b/Guide/view.markdown @@ -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: @@ -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|