# GraphQL API

# GraphQL API

Madori builds GraphQL schema from current collection definitions, blueprints, fieldsets, and resolver ports. Collection, global, taxonomy, and navigation fields are exposed when corresponding definitions exist. Schema is rebuilt when endpoint is composed, so definition changes are picked up by subsequent requests.

In development, a GraphiQL interface is available at the endpoint URL for exploring and testing queries interactively.

Collection and auxiliary resolvers are permission-guarded: unauthenticated GraphQL requests are denied. Control Panel sessions authenticate with the `madori_session` cookie; external callers can send a valid session token as `Authorization: Bearer <token>`. For anonymous browser rendering, use `@madori/sdk/hooks/client`, which reads published entries through the public content endpoint.

---

## Configuration Reference

### Endpoint Configuration

Configure GraphQL behaviour in `madori.config.ts`:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `graphql.enabled` | `boolean` | `true` | Enable or disable the GraphQL API |
| `graphql.path` | `string` | `/api/graphql` | URL path for the GraphQL endpoint |
| `graphql.introspection` | `boolean` | `true` in dev, `false` in prod | Allow schema introspection queries |

```ts
// madori.config.ts
graphql: {
  enabled: true,
  path: '/api/graphql',
  introspection: process.env.NODE_ENV !== 'production',
}
```

### Schema Generation Rules

For each collection with a blueprint, Madori generates:

| Generated Item | Naming | Description |
|----------------|--------|-------------|
| Type | PascalCase of handle | Type with all entry + blueprint fields |
| Singular query | camelCase of handle | Returns a single entry by slug |
| Plural query | camelCase plural of handle | Returns a filtered list |
| Filter input | `{BlueprintType}FilterInput` | Optional filter fields for list queries; name follows referenced blueprint |

### Standard Entry Fields

Every collection type includes these built-in fields:

| Field | GraphQL Type | Description |
|-------|--------------|-------------|
| `title` | `String` | Entry title |
| `slug` | `String` | URL slug identifier |
| `status` | `String` | `published` or `draft` |
| `author` | `String` | Author identifier |
| `content` | `String` | Markdown body content |
| `createdAt` | `String` | ISO 8601 timestamp |
| `updatedAt` | `String` | ISO 8601 timestamp |

### Blueprint Field Type Mapping

| Blueprint Type | GraphQL Type |
|---------------|--------------|
| `text`, `slug`, `markdown`, `tiptap`, `select`, `date`, `asset` (single), `yaml`, `code` | `String` |
| `number` | `Float` (or `Int` with `options.integer: true`) |
| `toggle` | `Boolean` |
| `multiselect`, `entries`, `taxonomy`, `asset` (multiple) | `[String]` |
| `replicator`, `blocks`, `grid` | Structured list when configured sets resolve; otherwise `JSON` |

### List Query Arguments

| Argument | Type | Default | Description |
|----------|------|---------|-------------|
| `filter` | `{BlueprintType}FilterInput` | — | Key-value object matching field values; type name follows referenced blueprint |
| `limit` | `Int` | all | Maximum entries to return |
| `offset` | `Int` | `0` | Skip N entries (for pagination) |
| `sort` | `String` | — | Format: `"fieldName:direction"` (e.g. `"createdAt:desc"`) |

### Additional Queries

| Query | Arguments | Returns | Description |
|-------|-----------|---------|-------------|
| `global(handle: String!)` | handle | `Global` | Get a global's data |
| `globals` | none | `[Global]` | List all globals |
| `taxonomy(handle: String!)` | handle | `Taxonomy` | Get taxonomy definition |
| `taxonomies` | none | `[Taxonomy]` | List all taxonomy definitions |
| `terms(taxonomy: String!)` | taxonomy | `[Term]` | Get taxonomy terms |
| `navigation(handle: String!)` | handle | `Navigation` | Get a navigation tree |
| `navigations` | none | `[Navigation]` | List all navigations |

### SEO Queries and Mutations

When `seo.enabled` is true, the schema adds permission-guarded SEO operations. SEO reads require `view` on `seo`; previews expose provenance only to authorized callers. Redirect writes require `edit` on `seo-redirects` and deletes require `delete` on `seo-redirects`; report reads require `view` on `seo-reports`.

| Operation | Arguments | Purpose |
|-----------|-----------|---------|
| `seoSite` | `site: String!` | Read site SEO defaults and revision |
| `seoSection` | `section: String!, handle: String!` | Read collection/taxonomy defaults and revision |
| `seoResolved` | `site`, `collection`, `slug` | Read published resolved SEO output |
| `seoPreview` | `site`, `collection`, `slug` | Read authenticated preview output plus provenance |
| `seoResolvedTerm` | `site`, `taxonomy`, `slug` | Read published resolved SEO for a taxonomy term |
| `seoPreviewTerm` | `site`, `taxonomy`, `slug` | Read authenticated term preview plus provenance |
| `seoReport` | optional `id`, optional `site` | Read latest or selected persisted audit report, optionally site-filtered |
| `seoRedirect` | `id: String!` | Read one redirect |
| `seoRedirects` | optional `site` | List redirects, optionally scoped to site |
| `seoSaveSite` | `document`, optional `expectedRevision` | Save site defaults with optimistic concurrency |
| `seoSaveSection` | `document`, optional `expectedRevision` | Save section defaults with optimistic concurrency |
| `seoSaveRedirect` | `redirect`, optional `expectedRevision` | Save validated redirect |
| `seoDeleteRedirect` | `id`, optional `expectedRevision` | Delete redirect |

Example resolved query:

```graphql
query SeoForPage {
  seoResolved(site: "default", collection: "pages", slug: "about") {
    excluded
    title
    description
    canonical
    indexing
    following
    socialImage
    jsonLdEnabled
    jsonLdType
    alternates { locale url }
  }
}
```

SEO GraphQL never returns server filesystem paths or operational storage details. If `seo.enabled` is false, SEO fields are not registered. Feature-specific switches (`metadata`, `structuredData`, `reports`, and `redirects`) further limit their corresponding outputs and operations.

Custom JSON-LD is available through the `jsonLd.custom` `SeoJSON` scalar. Supply JSON-LD containing an `@type` through GraphQL variables; the same depth, key-count, and size limits used by REST and file storage are applied before persistence.

---

## Usage Examples

### Single Entry Query

```graphql
{
  blog(slug: "hello-world") {
    title
    content
    createdAt
    featured_image
    tags
  }
}
```

### List with Filtering and Pagination

List field is normally pluralised (`blog` → `blogs`). A handle already ending in `s` uses a `List` suffix for its list field (`pages` → `pagesList`) so singular and list operations remain distinct.

```graphql
{
  blogs(
    filter: { status: "published" }
    limit: 10
    offset: 0
    sort: "createdAt:desc"
  ) {
    title
    slug
    createdAt
    featured_image
  }
}
```

### Multiple Filters

```graphql
{
  blogs(
    filter: { status: "published", author: "admin" }
    limit: 5
    sort: "createdAt:desc"
  ) {
    title
    slug
  }
}
```

### Querying Globals

```graphql
{
  global(handle: "site-settings") {
    data
  }
}
```

### Querying Navigation Trees

```graphql
{
  navigation(handle: "main") {
    handle
    items
  }
}
```

### Querying Taxonomy Terms

```graphql
{
  taxonomies {
    handle
    title
    blueprint
  }

  terms(taxonomy: "tags") {
    title
    slug
    taxonomy
    description
    data
  }
}
```

### Using with graphql-request (optional)

The following is an illustration for projects that install `graphql-request`; it is not a Madori dependency.

```ts
import { gql, GraphQLClient } from 'graphql-request'

const client = new GraphQLClient('http://localhost:3000/api/graphql', {
  headers: { Authorization: `Bearer ${process.env.MADORI_SESSION_TOKEN}` },
})

const POSTS_QUERY = gql`
  query GetPosts($limit: Int, $offset: Int) {
    blogs(
      filter: { status: "published" }
      limit: $limit
      offset: $offset
      sort: "createdAt:desc"
    ) {
      title
      slug
      content
      createdAt
      featured_image
    }
  }
`

const data = await client.request(POSTS_QUERY, {
  limit: 10,
  offset: 0,
})
```

### Using with Apollo Client (optional)

The following is an illustration for projects that install `@apollo/client`; it is not a Madori dependency.

```ts
import { ApolloClient, InMemoryCache, gql } from '@apollo/client'

const client = new ApolloClient({
  uri: 'http://localhost:3000/api/graphql',
  credentials: 'same-origin', // sends the signed-in madori_session cookie
  cache: new InMemoryCache(),
})

const { data } = await client.query({
  query: gql`
    {
      blogs(filter: { status: "published" }, limit: 10, sort: "createdAt:desc") {
        title
        slug
        createdAt
      }
    }
  `,
})
```

### Using with fetch (No Library)

```ts
async function queryGraphQL(query: string, variables?: Record<string, unknown>) {
  const response = await fetch('http://localhost:3000/api/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.MADORI_SESSION_TOKEN}`,
    },
    body: JSON.stringify({ query, variables }),
  })

  const json = await response.json()
  if (json.errors) throw new Error(json.errors[0].message)
  return json.data
}

const data = await queryGraphQL(`
  {
    blogs(limit: 5, sort: "createdAt:desc") {
      title
      slug
    }
  }
`)
```

### Next.js Server Component Integration

```tsx
async function getPublishedPosts() {
  const response = await fetch(`${process.env.SITE_URL}/api/graphql`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.MADORI_SESSION_TOKEN}`,
    },
    body: JSON.stringify({
      query: `{
        blogs(filter: { status: "published" }, sort: "createdAt:desc") {
          title
          slug
          createdAt
          featured_image
        }
      }`,
    }),
    next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
  })

  const json = await response.json()
  return json.data.blogs
}

export default async function BlogList() {
  const posts = await getPublishedPosts()

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  )
}
```

---

## Common Patterns

### Pagination

Implement offset-based pagination using `limit` and `offset`:

```graphql
# Page 1 (items 1-10)
{ blogs(limit: 10, offset: 0) { title slug } }

# Page 2 (items 11-20)
{ blogs(limit: 10, offset: 10) { title slug } }

# Page 3 (items 21-30)
{ blogs(limit: 10, offset: 20) { title slug } }
```

### Sort Patterns

The `sort` argument uses `"field:direction"` format:

```graphql
# Newest first
{ blogs(sort: "createdAt:desc") { title } }

# Alphabetical
{ blogs(sort: "title:asc") { title } }

# By update date
{ blogs(sort: "updatedAt:desc") { title } }
```

### Combining Queries

Request data from multiple sources in a single query:

```graphql
{
  siteSettings: global(handle: "site-settings") {
    data
  }

  mainNav: navigation(handle: "main") {
    items {
      label
      url
      children { label url }
    }
  }

  recentPosts: blogs(limit: 3, sort: "createdAt:desc") {
    title
    slug
  }
}
```

### Draft Preview

Query draft entries for preview functionality (requires authentication):

```graphql
{
  blog(slug: "upcoming-post") {
    title
    content
    status
  }
}
```

### Disabling Introspection in Production

Prevent schema exposure in production by setting introspection to `false`:

```ts
// madori.config.ts
graphql: {
  enabled: true,
  path: '/api/graphql',
  introspection: false,
}
```

This disables the `__schema` and `__type` queries while leaving all other queries functional.

### Static Site Generation

Pre-render pages with the server SDK, which reads local flat files without exposing an authentication token:

```tsx
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const client = madoriClient<Collections>()
  const posts = await client.listEntries('blog', { status: 'published' })

  return posts.map((post) => ({ slug: post.slug }))
}
```

### Using the Typed SDK

For type-safe content queries without writing GraphQL manually, use the `@madori/sdk` package.

**Server Components:**

```tsx
import { madoriClient } from '@madori/sdk/hooks/server'
import type { Collections } from '@/.madori/generated'

const client = madoriClient<Collections>()

export default async function BlogList() {
  const posts = await client.listEntries('blog', {
    sort: '-createdAt',
    status: 'published',
    limit: 10,
  })

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>{post.title}</li>
      ))}
    </ul>
  )
}
```

**With Next.js Cache Tags (for on-demand revalidation):**

```tsx
import { madoriClient, taggedListEntries } from '@madori/sdk/hooks/server'
import type { Collections } from '@/.madori/generated'

const client = madoriClient<Collections>()
const listEntries = taggedListEntries(client)

// Entries are cached with tag 'madori:collection:blog'
// Revalidate with: revalidateTag('madori:collection:blog')
const posts = await listEntries('blog', { status: 'published' })
```

**Client Components:**

```tsx
'use client'
import { useMadoriEntries } from '@madori/sdk/hooks/client'

export function RecentPosts() {
  const { data: posts, isLoading, error } = useMadoriEntries('blog', {
    limit: 5,
    sort: '-createdAt',
  })

  if (isLoading) return <p>Loading...</p>
  if (error) return <p>Error loading posts</p>

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>{post.title}</li>
      ))}
    </ul>
  )
}
```

Generated projects do not bundle a CLI. Manage content with control panel, or run CLI tooling from Madori source checkout.