Skip to content

Latest commit

 

History

History
558 lines (461 loc) · 13.5 KB

File metadata and controls

558 lines (461 loc) · 13.5 KB

hydro AI context

Use this file as the first context block when asking an AI coding tool to add or modify hydro resources in a Nuxt codebase.

hydro creates resource-oriented REST APIs for Nuxt and Nitro. A resource definition generates CRUD routes, request validation, query parsing, relationship writes, custom operations, RFC 7807 errors, and OpenAPI docs.

Install

npm install @muffe/hydro zod

Nuxt configuration

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@muffe/hydro'],
  hydro: {
    prefix: '/api',
    resourcesDir: 'server/resources',
    openapi: {
      enabled: true,
      info: { title: 'My API', version: '1.0.0' },
    },
  },
})

Resource file convention

Put one resource per file in server/resources/. Export defineResource() as the default export.

// server/resources/book.ts
import { defineResource } from '#hydro'
import { z } from 'zod'

interface Book {
  id?: string
  title: string
  authorId?: string
  available: boolean
}

const books: Book[] = []

export default defineResource<Book>({
  name: 'Book',
  path: 'books',
  schema: z.object({
    id: z.string().optional(),
    title: z.string().min(1).max(200),
    authorId: z.string().optional(),
    available: z.boolean().default(true),
  }),
  filters: ['title', 'authorId', 'available'],
  pagination: { default: 10, max: 50 },
  provider: {
    async list(ctx) {
      let items = [...books]

      if (ctx.query.filters.title) {
        items = items.filter(book => book.title.includes(String(ctx.query.filters.title)))
      }
      if (ctx.query.filters.authorId) {
        items = items.filter(book => book.authorId === String(ctx.query.filters.authorId))
      }
      if (ctx.query.filters.available !== undefined) {
        items = items.filter(book => String(book.available) === String(ctx.query.filters.available))
      }

      for (const sort of [...ctx.query.sort].reverse()) {
        items = items.sort((left, right) => {
          const leftValue = String(left[sort.field as keyof Book] ?? '')
          const rightValue = String(right[sort.field as keyof Book] ?? '')
          const result = leftValue.localeCompare(rightValue)
          return sort.direction === 'asc' ? result : -result
        })
      }

      const { offset, itemsPerPage } = ctx.query.pagination
      const page = items.slice(offset, offset + itemsPerPage)

      return {
        items: selectFields(page, ctx.query.fields),
        total: items.length,
      }
    },
    async get(ctx) {
      return books.find(book => book.id === ctx.params.id) ?? null
    },
  },
  processor: {
    async create(ctx) {
      const book = { available: true, ...ctx.input, id: crypto.randomUUID() } as Book
      books.push(book)
      return book
    },
    async update(ctx) {
      const index = books.findIndex(book => book.id === ctx.params.id)
      const next = { ...books[index], ...ctx.input, id: ctx.params.id } as Book
      books[index] = next
      return next
    },
    async delete(ctx) {
      const index = books.findIndex(book => book.id === ctx.params.id)
      if (index >= 0) books.splice(index, 1)
    },
  },
})

function selectFields<T extends object>(items: T[], fields?: string[]): Array<T | Partial<T>> {
  if (!fields?.length) return items
  return items.map((item) => {
    const selected: Partial<T> = {}
    for (const field of fields) {
      if (field in item) selected[field as keyof T] = item[field as keyof T]
    }
    return selected
  })
}

Generated CRUD routes

For path: 'books' and prefix: '/api', hydro generates:

GET    /api/books
GET    /api/books/:id
POST   /api/books
PATCH  /api/books/:id
DELETE /api/books/:id

GET /api/books returns the list body as JSON and includes pagination metadata in headers when provided by the provider:

X-Total-Count: 42
X-Has-More: true

Provider and processor responsibilities

Provider handles reads:

provider: {
  async list(ctx) {
    return { items, total }
  },
  async get(ctx) {
    return item ?? null
  },
}

Processor handles writes:

processor: {
  async create(ctx) {
    return created
  },
  async update(ctx) {
    return updated
  },
  async delete(ctx) {
    // No return needed. Hydro responds with 204.
  },
}

Context object

All handlers receive ctx.

ctx.params.id              // Route id from /books/:id
ctx.query.filters          // Parsed allow-listed filters
ctx.query.sort             // [{ field, direction }]
ctx.query.pagination       // { page, itemsPerPage, offset }
ctx.query.fields           // Sparse fieldset string[] or undefined
ctx.input                  // Validated request body for writes and custom ops
ctx.auth                   // Auth info when auth is enabled
ctx.request                // Request info with method, url, headers, raw

Example usage:

async list(ctx) {
  const { filters, sort, pagination, fields } = ctx.query
  const rows = await db.books.findMany({
    where: { title: filters.title ? String(filters.title) : undefined },
    skip: pagination.offset,
    take: pagination.itemsPerPage,
  })
  return { items: applyFields(rows, fields), total: await db.books.count() }
}

async create(ctx) {
  return db.books.create({ data: ctx.input })
}

Query parameters

Declare filterable fields on the resource:

export default defineResource({
  // ...
  filters: ['title', 'authorId', 'available'],
})

Supported query parameters:

GET /api/books?title=Dune
GET /api/books?authorId=a1&available=true
GET /api/books?order[title]=asc
GET /api/books?page=2&itemsPerPage=25
GET /api/books?fields=id,title,available
GET /api/books?title=Dune&order[title]=asc&page=1&itemsPerPage=10&fields=id,title

Parsed shape:

// GET /api/books?title=Dune&order[title]=asc&page=2&itemsPerPage=10&fields=id,title
ctx.query = {
  filters: { title: 'Dune' },
  sort: [{ field: 'title', direction: 'asc' }],
  pagination: { page: 2, itemsPerPage: 10, offset: 10 },
  fields: ['id', 'title'],
}

Relationships

Relationships live in the resource definition.

belongsTo reference writes

Use belongsTo on the resource that stores the foreign key.

// server/resources/book.ts
export default defineResource({
  name: 'Book',
  path: 'books',
  schema: z.object({
    id: z.string().optional(),
    title: z.string().min(1),
    authorId: z.string().optional(),
  }),
  relationships: {
    author: {
      kind: 'belongsTo',
      target: 'Author',
      foreignKey: 'authorId',
      writable: { create: 'reference', update: 'reference' },
    },
  },
})

Client request:

POST /api/books
Content-Type: application/json

{
  "title": "The Fifth Season",
  "author": "/authors/a5"
}

Hydro verifies Author a5 exists, removes author, and gives the processor ctx.input with authorId: 'a5'.

hasMany nested writes

Use hasMany on the parent resource.

// server/resources/author.ts
export default defineResource({
  name: 'Author',
  path: 'authors',
  schema: z.object({
    id: z.string().optional(),
    name: z.string().min(1),
    country: z.string().length(2),
    books: z.array(z.object({ title: z.string().min(1) })).optional(),
  }),
  relationships: {
    books: {
      kind: 'hasMany',
      target: 'Book',
      foreignKey: 'authorId',
      writable: { create: 'nested', update: false },
    },
  },
})

Client request:

POST /api/authors
Content-Type: application/json

{
  "name": "Martha Wells",
  "country": "US",
  "books": [{ "title": "All Systems Red" }]
}

Hydro creates the Author first, then validates and creates each nested Book with the new authorId.

Writable modes:

writable: { create: 'reference', update: 'reference' } // Accept string id or path
writable: { create: 'nested', update: false }          // Accept inline objects on create
writable: { create: 'either', update: 'either' }       // Accept references or inline objects
writable: { create: false, update: false }             // Field is not writable

Custom operations

Use custom operations for behavior that is not plain CRUD. They validate input and output and appear in OpenAPI.

Item operation

customOperations: {
  checkout: {
    name: 'checkout',
    method: 'POST',
    scope: 'item',
    path: 'checkout',
    input: z.object({ userId: z.string().min(1) }),
    output: z.object({ id: z.string().optional(), available: z.boolean() }),
    async handler(ctx) {
      const book = books.find(book => book.id === ctx.params.id)!
      book.available = false
      return { id: book.id, available: book.available }
    },
  },
}

Generated route:

POST /api/books/:id/checkout

Collection operation

customOperations: {
  spotlight: {
    name: 'spotlight',
    method: 'POST',
    scope: 'collection',
    path: 'spotlight',
    input: z.object({ country: z.string().length(2).optional() }).optional(),
    output: z.array(z.object({
      id: z.string().optional(),
      name: z.string(),
      bookCount: z.number(),
    })),
    async handler(ctx) {
      const country = ctx.input?.country?.toUpperCase()
      return authors
        .filter(author => author.featured && (!country || author.country === country))
        .map(author => ({ id: author.id, name: author.name, bookCount: authorBookCount(author.id) }))
    },
  },
}

Generated route:

POST /api/authors/spotlight

For item operations, loadEntity: true asks Hydro to call provider.get before the handler and provide ctx.entity. Missing entities return 404 automatically.

checkout: {
  name: 'checkout',
  method: 'POST',
  scope: 'item',
  path: 'checkout',
  loadEntity: true,
  async handler(ctx) {
    return ctx.entity
  },
}

Errors

Validation errors return RFC 7807 Problem Details as application/problem+json.

POST /api/authors
Content-Type: application/json

{ "name": "", "country": "USA" }

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://hydro.dev/errors/validation",
  "title": "Validation Failed",
  "status": 422,
  "detail": "One or more fields failed validation",
  "violations": [
    { "propertyPath": "/name", "message": "Required" },
    { "propertyPath": "/country", "message": "Expected 2 characters" }
  ]
}

Core error classes are exported from @muffe/hydro/core:

import {
  ConflictError,
  ForbiddenError,
  HydroError,
  NotFoundError,
  UnauthorizedError,
  ValidationError,
} from '@muffe/hydro/core'

throw new ValidationError([{ propertyPath: '/email', message: 'Already taken' }])
throw new NotFoundError('Book not found')
throw new ConflictError('Duplicate slug')
throw new ForbiddenError('Not the owner')
throw new UnauthorizedError('Login required')
throw new HydroError(429, 'Too Many Requests', 'Try again later')

Auth

Module config:

export default defineNuxtConfig({
  hydro: {
    auth: {
      enabled: true,
      provider: 'nuxt-auth-utils',
      defaultAccess: { development: 'public', production: 'deny' },
      session: { userId: 'user.id', roles: 'user.roles' },
      policies: {
        ownsResource({ auth, entity }) {
          return auth?.userId === entity?.ownerId
        },
      },
    },
  },
})

Resource access rules:

export default defineResource({
  // ...
  auth: {
    default: 'authenticated',
    operations: {
      list: 'public',
      create: { roles: ['editor'] },
      update: { policy: 'ownsResource', needsEntity: true },
    },
  },
})

Access values:

'public'
'authenticated'
'deny'
{ roles: ['editor'] }
{ permissions: ['books:update'] }
{ policy: 'ownsResource', needsEntity: true }

OpenAPI docs

When enabled, Hydro generates:

GET /api/_openapi.json
GET /api/_docs

Disable:

hydro: {
  openapi: { enabled: false },
}

AI coding rules

When modifying a foreign Nuxt codebase that uses hydro, follow these rules:

  1. Look for hydro config in nuxt.config.ts to confirm prefix and resources directory.
  2. Put resource files in the configured resources directory, usually server/resources/.
  3. Import defineResource from #hydro inside Nuxt resource files.
  4. Use Zod schemas unless the project already uses another Standard Schema library.
  5. Keep reads in provider and writes in processor.
  6. Use ctx.query instead of manually parsing query strings.
  7. Use ctx.input as trusted validated input, but still handle business constraints.
  8. Return null from provider.get for missing records. Hydro maps this to 404.
  9. Return { items, total } from provider.list when possible.
  10. Use relationships for reference or nested writes instead of manually accepting both shapes in processors.
  11. Use custom operations for actions that are not CRUD.
  12. Use RFC 7807 error classes from @muffe/hydro/core for business errors.
  13. Keep resource name PascalCase and path plural kebab-case.
  14. Do not generate manual Nitro route files for CRUD endpoints that Hydro already generates.

Copy-paste prompt

I am working in a Nuxt codebase that uses hydro.
Use this context as the source of truth: https://hydro-playground.vercel.app/llms-full.txt

Before coding:
- Read nuxt.config.ts and confirm hydro.prefix and hydro.resourcesDir.
- Inspect existing files in the resources directory and match their style.
- Add or edit one resource per file.
- Import defineResource from #hydro.
- Use Zod schemas.
- Keep reads in provider and writes in processor.
- Use ctx.query for filters, sort, pagination and sparse fields.
- Use ctx.input for validated request bodies.
- Use relationships and customOperations when appropriate.
- Verify with typecheck and relevant tests.