> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fetchski-attract.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Calling the API

> Controllers and routes, validated writes, uploads, and the site's errors turned into your app's responses.

Every route in the API reference is a method on one of the client's accessors (`pages()`,
`blogPosts()`, `testimonials()` …), and every answer is a typed model. How the calls themselves work
(arguments, models, ids, scopes, paging) is the same as in plain TypeScript:
[TypeScript → Calling the API](https://docs.fetchski-attract.com/typescript/calling-the-api.md). This page shows where they go in AdonisJS.

## Read in a controller

**1. The controller**

```bash Terminal theme={"dark"}
node ace make:controller blog_posts
```

```ts app/controllers/blog_posts_controller.ts theme={"dark"}
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { SiteClient } from '@fetchski/attract-cms-sdk'

@inject()
export default class BlogPostsController {
  constructor(protected site: SiteClient) {}

  async index({ request }: HttpContext) {
    return this.site.blogPosts().listBlogs({ page: request.input('page', 1), perPage: 20 })
  }

  async show({ params, response }: HttpContext) {
    const blog = await this.site.blogPosts().findBlog({ id: params.id })
    return blog ?? response.notFound({ message: 'No such post.' })
  }
}
```

`findBlog()` (and every `find…()`) answers `null` for a 404 instead of throwing.

**2. The routes**

```ts start/routes.ts theme={"dark"}
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router.get('blog', [controllers.BlogPosts, 'index'])
router.get('blog/:id', [controllers.BlogPosts, 'show'])
```

`#generated/controllers` is regenerated by `node ace serve` and `node ace test`; run
`node ace codegen` before a bare `tsc`.

## Write, after your own validation

Validate what your visitors send with VineJS first, so a bad request never reaches the site. This one
saves a visitor's testimonial as a **draft** for an editor to review and publish in the CMS:

```ts app/controllers/testimonials_controller.ts theme={"dark"}
import { randomUUID } from 'node:crypto'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { AposMode, SiteClient } from '@fetchski/attract-cms-sdk'
import { testimonialValidator } from '#validators/testimonial'

@inject()
export default class TestimonialsController {
  constructor(protected site: SiteClient) {}

  /** A visitor's testimonial, saved as a draft for an editor to review and publish in the CMS. */
  async store({ request, response }: HttpContext) {
    const { authorName, quote, rating } = await request.validateUsing(testimonialValidator)

    const draft = await this.site.testimonials().createTestimonial({
      title: `Testimonial from ${authorName}`,
      slug: `testimonial-${randomUUID()}`,
      quote,
      authorName,
      rating,
      aposMode: AposMode.Draft,
    })

    return response.created({ id: draft.id })
  }
}
```

```ts app/validators/testimonial.ts theme={"dark"}
import vine from '@vinejs/vine'

export const testimonialValidator = vine.create({
  authorName: vine.string().trim().maxLength(80),
  quote: vine.string().trim().minLength(10).maxLength(1000),
  rating: vine.number().range([1, 5]),
})
```

Route it beside the blog's:

```ts start/routes.ts theme={"dark"}
router.post('testimonials', [controllers.Testimonials, 'store'])
```

> [!NOTE]
>
> A draft write needs the type's write scope, here `content:testimonials:write`. Without
> `aposMode: 'draft'` the create also takes it live, which needs `content:testimonials:publish` too
> ([What an API key reaches](https://docs.fetchski-attract.com/typescript/calling-the-api.md#what-an-api-key-reaches)).

## Upload a visitor's file

The site reads a file's type from its name, so keep the extension:

```ts app/controllers/attachments_controller.ts theme={"dark"}
import { readFile } from 'node:fs/promises'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import vine from '@vinejs/vine'
import { SiteClient } from '@fetchski/attract-cms-sdk'

const uploadValidator = vine.create({
  photo: vine.file({ size: '10mb', extnames: ['jpg', 'jpeg', 'png', 'webp'] }),
})

@inject()
export default class AttachmentsController {
  constructor(protected site: SiteClient) {}

  async store({ request }: HttpContext) {
    const { photo } = await request.validateUsing(uploadValidator)

    // The site reads a file's type from its name: keep the visitor's extension.
    const file = new File([await readFile(photo.tmpPath!)], `upload.${photo.extname}`, {
      type: `${photo.type}/${photo.subtype}`,
    })
    return this.site.attachments().uploadAttachment({ file })
  }
}
```

```ts start/routes.ts theme={"dark"}
router.post('attachments', [controllers.Attachments, 'store'])
```

An upload needs a media type's write scope (`content:images:write` here). A key with a signing secret
signs it by its SHA-256, as every write ([Signing requests](https://docs.fetchski-attract.com/signing.md)).

## Map SDK errors to responses

Anything the SDK throws is an `AttractError`, and each carries the site's own HTTP `status`. Left alone,
AdonisJS's exception handler answers your caller with that status, so a key the site refuses (`401`)
would look like **your** caller isn't signed in. Map them once:

```ts app/exceptions/handler.ts theme={"dark"}
import app from '@adonisjs/core/services/app'
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import {
  AttractError,
  NotFound,
  RateLimited,
  UnknownSite,
  ValidationFailed,
} from '@fetchski/attract-cms-sdk'

export default class HttpExceptionHandler extends ExceptionHandler {
  protected debug = !app.inProduction

  async handle(error: unknown, ctx: HttpContext) {
    if (error instanceof NotFound || error instanceof UnknownSite) {
      return ctx.response.notFound({ message: 'Not found.' })
    }
    if (error instanceof ValidationFailed) {
      return ctx.response.unprocessableEntity({ message: error.message, errors: error.errors })
    }
    if (error instanceof RateLimited) {
      ctx.response.header('Retry-After', String(error.retryAfter ?? 60))
      return ctx.response.serviceUnavailable({ message: 'The site is busy: try again shortly.' })
    }
    if (error instanceof AttractError) {
      return ctx.response.badGateway({ message: 'The site could not answer: try again shortly.' })
    }
    return super.handle(error, ctx)
  }

  async report(error: unknown, ctx: HttpContext) {
    return super.report(error, ctx)
  }
}
```

> [card] Reported, never leaked
>
> `report()` logs each one with its message (the site, route and status), never a key. Your caller
> sees only your own words.

> [card] Retries already done
>
> The SDK retried what was safe before it threw: a `RateLimited` here has already waited.
> [What is retried](https://docs.fetchski-attract.com/typescript/errors.md#what-is-retried).

> [!TIP]
>
> `ValidationFailed.errors` maps each field path to the site's messages
> (`{ quote: ['Required.'] }`): pass them on, as above, when your caller sent those fields.

## Long lists and slow calls

> [card] [One page per request](https://docs.fetchski-attract.com/typescript/paging.md)
>
> A controller reads one page (`listBlogs({ page })`); `…All()` walks every page, best in a job; `…Pick()` walks it for only the fields you name ([Find an id](https://docs.fetchski-attract.com/find-an-id.md)).

> [card] [AI routes set their own timeout](https://docs.fetchski-attract.com/typescript/calling-the-api.md#timeouts)
>
> Run them from a queue job, not a request: see [Jobs, cache and health](https://docs.fetchski-attract.com/adonisjs/background-work.md).

> [card] [Next: testing](https://docs.fetchski-attract.com/adonisjs/testing.md)
>
> Japa tests with `fakeAttract()`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.