laravel-query-gate-sdk
v1.0.3
Published
Contract-driven TypeScript SDK for Laravel Query Gate
Maintainers
Readme
Laravel Query Gate SDK
A contract-driven TypeScript SDK for Laravel Query Gate that provides strongly-typed API interactions with compile-time safety.
Features
- Contract-Driven: One contract per resource defines all operations
- Type-Safe: Full TypeScript support with compile-time validation
- Fluent API: Chainable, immutable builder pattern
- Laravel-Native Error Handling: Built-in support for all Laravel error responses
- Zero Runtime Overhead: Contracts exist only for TypeScript, no reflection
- Framework Agnostic: Works with any frontend framework or Node.js
Installation
npm install laravel-query-gate-sdkyarn add laravel-query-gate-sdkpnpm add laravel-query-gate-sdkQuick Start
import { queryGate, configureQueryGate } from 'laravel-query-gate-sdk'
// 1. Configure the SDK
configureQueryGate({
baseUrl: 'https://api.example.com',
defaultHeaders: {
Authorization: 'Bearer your-token',
},
})
// 2. Define your contract
interface PostContract {
get: Post[]
create: CreatePostPayload
update: UpdatePostPayload
}
// 3. Use the SDK
const posts = await queryGate<PostContract>('posts')
.filter('status', 'eq', 'published')
.get()Table of Contents
- Configuration
- Defining Contracts
- Read Operations
- Write Operations
- Custom Actions
- Query Builder
- Versioning
- Headers & Options
- Error Handling
- API Reference
Configuration
Global Configuration
Configure the SDK once at application startup:
import { configureQueryGate } from 'laravel-query-gate-sdk'
configureQueryGate({
baseUrl: 'https://api.example.com',
defaultHeaders: {
'Authorization': 'Bearer token',
'X-Tenant-ID': 'tenant-1',
},
defaultFetchOptions: {
credentials: 'include',
mode: 'cors',
},
})Isolated Instances
For multi-tenant applications or testing, create isolated instances:
import { createQueryGate } from 'laravel-query-gate-sdk'
const tenantApi = createQueryGate({
baseUrl: 'https://tenant1.api.example.com',
})
const posts = await tenantApi<PostContract>('posts').get()Configuration Options
| Option | Type | Description |
|--------|------|-------------|
| baseUrl | string | Required. Base URL for all API requests |
| defaultHeaders | Record<string, string> | Headers included in every request |
| defaultFetchOptions | Partial<RequestInit> | Default fetch options (credentials, mode, etc.) |
Defining Contracts
Contracts define the shape of your API resources. Each resource has one contract that describes:
- Read operations (
get) - Write operations (
create,update) - Custom actions (
actions)
Basic Contract
import type { ResourceContract } from 'laravel-query-gate-sdk'
interface Post {
id: number
title: string
content: string
status: 'draft' | 'published'
created_at: string
}
interface CreatePostPayload {
title: string
content: string
}
interface UpdatePostPayload {
title?: string
content?: string
status?: 'draft' | 'published'
}
interface PostContract extends ResourceContract {
get: Post[]
create: CreatePostPayload
update: UpdatePostPayload
}Contract with Custom Actions
interface PostContract extends ResourceContract {
get: Post[]
create: CreatePostPayload
update: UpdatePostPayload
actions: {
// Action without payload
publish: {
method: 'post'
payload?: never
response: Post
}
// Action with payload
bulkPublish: {
method: 'post'
payload: { ids: number[] }
response: { updated: number }
}
// GET action
stats: {
method: 'get'
payload?: never
response: { total: number; published: number }
}
// Search action
search: {
method: 'post'
payload: { query: string; filters?: Record<string, unknown> }
response: Post[]
}
}
}Read-Only Contract
If a resource only supports reading:
interface ReadOnlyPostContract extends ResourceContract {
get: Post[]
// No create or update - those methods will be hidden by TypeScript
}Read Operations
Fetch Collection
const posts = await queryGate<PostContract>('posts').get()
// posts: Post[]Fetch Single Resource
const post = await queryGate<PostContract>('posts').id(1).get()
// post: PostWith Query Parameters
const posts = await queryGate<PostContract>('posts')
.filter('status', 'eq', 'published')
.filter('author_id', 'eq', 1)
.sort('created_at', 'desc')
.get()Write Operations
Create (POST)
const newPost = await queryGate<PostContract>('posts').post({
title: 'My New Post',
content: 'Hello, world!',
})
// newPost: PostUpdate (PATCH)
const updatedPost = await queryGate<PostContract>('posts')
.id(1)
.patch({
title: 'Updated Title',
status: 'published',
})
// updatedPost: PostDelete
await queryGate<PostContract>('posts').id(1).delete()Custom Actions
Custom actions allow you to call non-CRUD endpoints defined in your Laravel Query Gate resource.
Action Without Payload
// POST /posts/1/publish
const publishedPost = await queryGate<PostContract>('posts')
.id(1)
.action('publish')
.post()
// publishedPost: PostAction With Payload
// POST /posts/bulk-publish
const result = await queryGate<PostContract>('posts')
.action('bulkPublish')
.post({ ids: [1, 2, 3, 4, 5] })
// result: { updated: number }GET Action
// GET /posts/stats
const stats = await queryGate<PostContract>('posts')
.action('stats')
.get()
// stats: { total: number; published: number }Type Safety
TypeScript enforces correct usage:
// Error: payload not allowed for 'publish' action
await queryGate<PostContract>('posts')
.id(1)
.action('publish')
.post({ foo: 'bar' }) // TypeScript error!
// Error: payload required for 'bulkPublish' action
await queryGate<PostContract>('posts')
.action('bulkPublish')
.post() // TypeScript error!Query Builder
Filters
queryGate<PostContract>('posts')
.filter('status', 'eq', 'published')
.filter('views', 'gte', 100)
.filter('category_id', 'in', [1, 2, 3])
.get()
// Generates: ?filter[status][eq]=published&filter[views][gte]=100&filter[category_id][in]=1,2,3Filtering on Relations:
queryGate<PostContract>('posts')
.filter('author.name', 'like', 'John')
.filter('category.is_active', 'eq', 1)
.get()
// Generates: ?filter[author.name][like]=John&filter[category.is_active][eq]=1Available Operators:
| Operator | Description |
|----------|-------------|
| eq | Equal to |
| neq | Not equal to |
| gt | Greater than |
| gte | Greater or equal |
| lt | Less than |
| lte | Less or equal |
| in | In array |
| not_in | Not in array |
| like | Pattern match |
| between | Range (two values) |
Sorting
queryGate<PostContract>('posts')
.sort('created_at', 'desc')
.sort('title', 'asc')
.get()
// Generates: ?sort=created_at:desc,title:ascPagination
The SDK supports both Laravel pagination methods. The backend controls items per page.
Standard Pagination (Laravel paginate())
import type { PaginateResponse } from 'laravel-query-gate-sdk'
// First page
const posts = await queryGate<PostContract>('posts')
.paginate()
.get() as PaginateResponse<Post>
// Specific page
const page2 = await queryGate<PostContract>('posts')
.paginate(2)
.get() as PaginateResponse<Post>
// Response includes: current_page, data, first_page_url, last_page, total, etc.
console.log(posts.data) // Post[]
console.log(posts.current_page) // 1
console.log(posts.last_page) // 10
console.log(posts.total) // 100Cursor Pagination (Laravel cursorPaginate())
import type { CursorPaginateResponse } from 'laravel-query-gate-sdk'
// First page
const posts = await queryGate<PostContract>('posts')
.cursor()
.get() as CursorPaginateResponse<Post>
// URL: /posts
// Next page
const nextPage = await queryGate<PostContract>('posts')
.cursor(posts.next_cursor)
.get() as CursorPaginateResponse<Post>
// URL: /posts?cursor=eyJjcmVhdGVkX2F0Ijoi...
// Response includes: data, next_cursor, prev_cursor, next_page_url, prev_page_url
console.log(posts.data) // Post[]
console.log(posts.next_cursor) // "eyJjcmVhdGVkX2F0Ijoi..."Note: With cursor pagination, filters and sorts are not sent in the URL. The backend controls the query, and the cursor handles navigation.
No Pagination (Fetch All)
// Without .paginate() or .cursor(), fetches all records
const allPosts = await queryGate<PostContract>('posts').get()
// allPosts: Post[]Versioning
Laravel Query Gate supports API versioning via the X-Query-Version header:
const posts = await queryGate<PostContract>('posts')
.version('2.0')
.get()Headers & Options
Single Header
queryGate<PostContract>('posts')
.header('X-Custom-Header', 'value')
.get()Multiple Headers
queryGate<PostContract>('posts')
.headers({
'X-Custom-Header': 'value',
'X-Another-Header': 'another-value',
})
.get()Fetch Options
const controller = new AbortController()
queryGate<PostContract>('posts')
.options({
signal: controller.signal,
credentials: 'include',
mode: 'cors',
cache: 'no-cache',
})
.get()Error Handling
The SDK provides specific error classes for all common Laravel error responses.
Error Types
| Status | Error Class | Laravel Context |
|--------|-------------|-----------------|
| 401 | QueryGateUnauthorizedError | Auth middleware |
| 403 | QueryGateForbiddenError | Policy/Gate denial |
| 404 | QueryGateNotFoundError | Model not found |
| 419 | QueryGateCsrfMismatchError | CSRF token invalid/expired |
| 422 | QueryGateValidationError | Form Request validation |
| 429 | QueryGateRateLimitError | Throttle middleware |
| 500 | QueryGateServerError | Internal server error |
| 503 | QueryGateServiceUnavailableError | Maintenance mode |
| - | QueryGateNetworkError | Connection failed |
| - | QueryGateHttpError | Other HTTP errors |
Type Guards
import {
isUnauthorizedError,
isForbiddenError,
isNotFoundError,
isCsrfMismatchError,
isValidationError,
isRateLimitError,
isServerError,
isServiceUnavailableError,
isNetworkError,
isHttpError,
} from 'laravel-query-gate-sdk'Complete Error Handling Example
import {
queryGate,
isUnauthorizedError,
isForbiddenError,
isNotFoundError,
isCsrfMismatchError,
isValidationError,
isRateLimitError,
isServerError,
isServiceUnavailableError,
isNetworkError,
} from 'laravel-query-gate-sdk'
try {
await queryGate<PostContract>('posts').post({
title: 'New Post',
content: 'Content here',
})
} catch (error) {
// 401 - Unauthorized
if (isUnauthorizedError(error)) {
console.error('Please log in')
redirectToLogin()
return
}
// 403 - Forbidden
if (isForbiddenError(error)) {
console.error('You do not have permission')
return
}
// 404 - Not Found
if (isNotFoundError(error)) {
console.error('Resource not found')
return
}
// 419 - CSRF Token Mismatch
if (isCsrfMismatchError(error)) {
console.error('Session expired. Please refresh.')
refreshCsrfToken()
return
}
// 422 - Validation Error
if (isValidationError(error)) {
console.error('Validation failed:', error.originalMessage)
// Access all errors
console.error('Errors:', error.errors)
// { title: ['The title field is required.'], ... }
// Check specific field
if (error.hasFieldError('title')) {
console.error('Title error:', error.getFirstFieldError('title'))
}
// Get all fields with errors
console.error('Fields with errors:', error.getErrorFields())
return
}
// 429 - Rate Limit
if (isRateLimitError(error)) {
console.error('Too many requests')
if (error.hasRetryAfter()) {
console.error(`Retry after ${error.retryAfter} seconds`)
const retryDate = error.getRetryDate()
console.error(`Retry at: ${retryDate?.toLocaleTimeString()}`)
}
return
}
// 500 - Server Error
if (isServerError(error)) {
console.error('Server error. Please try again later.')
reportToErrorTracking(error)
return
}
// 503 - Service Unavailable (Maintenance)
if (isServiceUnavailableError(error)) {
console.error('Service temporarily unavailable')
if (error.hasRetryAfter()) {
console.error(`Back in approximately ${error.retryAfter} seconds`)
}
return
}
// Network Error
if (isNetworkError(error)) {
console.error('Network error:', error.originalError.message)
console.error('Please check your internet connection')
return
}
// Unknown error
throw error
}Validation Error Details
The QueryGateValidationError provides helper methods for working with Laravel's validation error format:
if (isValidationError(error)) {
// Get all errors for a field
const titleErrors: string[] = error.getFieldErrors('title')
// ['The title field is required.', 'The title must be at least 3 characters.']
// Get first error for a field
const firstError: string | undefined = error.getFirstFieldError('title')
// 'The title field is required.'
// Check if field has errors
const hasError: boolean = error.hasFieldError('title')
// true
// Get all fields with errors
const fields: string[] = error.getErrorFields()
// ['title', 'content', 'author_id']
// Access raw errors object
const rawErrors: Record<string, string[]> = error.errors
// { title: [...], content: [...] }
// Access original Laravel message
const message: string = error.originalMessage
// 'The given data was invalid.'
}Rate Limit & Service Unavailable Retry-After
Both QueryGateRateLimitError (429) and QueryGateServiceUnavailableError (503) support the Retry-After header:
if (isRateLimitError(error) || isServiceUnavailableError(error)) {
// Check if retry information is available
if (error.hasRetryAfter()) {
// Get seconds until retry
const seconds: number | null = error.retryAfter
// 60
// Get Date object for when retry is allowed
const retryDate: Date | null = error.getRetryDate()
// Date object
}
}API Reference
Entry Points
configureQueryGate(config: QueryGateConfig): void
Configure the SDK globally.
queryGate<T>(resource: string): QueryGateBuilder<T>
Create a builder for a resource using global configuration.
createQueryGate(config: QueryGateConfig): <T>(resource: string) => QueryGateBuilder<T>
Create an isolated SDK instance with its own configuration.
QueryGateBuilder Methods
| Method | Description |
|--------|-------------|
| .id(id) | Set resource ID for single resource operations |
| .filter(field, operator, value) | Add a filter |
| .sort(field, direction?) | Add sorting (default: 'asc') |
| .paginate(page?) | Use standard pagination (Laravel paginate()) |
| .cursor(cursor?) | Use cursor pagination (Laravel cursorPaginate()) |
| .version(version) | Set API version header |
| .header(key, value) | Add a single header |
| .headers(record) | Add multiple headers |
| .options(fetchOptions) | Set fetch options |
| .get() | Execute GET request |
| .post(payload) | Execute POST request |
| .action(name) | Access custom action |
QueryGateBuilderWithId Methods
Available after calling .id():
| Method | Description |
|--------|-------------|
| .get() | Fetch single resource |
| .patch(payload) | Update resource |
| .delete() | Delete resource |
| .action(name) | Execute action on resource |
Types
import type {
ResourceContract,
ActionContract,
FilterOperator,
SortDirection,
QueryGateConfig,
ValidationErrors,
// Pagination response types
PaginateResponse,
CursorPaginateResponse,
} from 'laravel-query-gate-sdk'Design Philosophy
The SDK follows these principles:
- Explicit contracts over magic - All behavior is declared in contracts
- Compile-time safety over runtime guessing - TypeScript catches errors before runtime
- Orchestration over domain logic - SDK is a transport layer only
- Consistency over brevity - Predictable API surface
Requirements
- Node.js 18+
- TypeScript 5.0+ (recommended)
License
MIT
Contributing
Contributions are welcome! Please read the contributing guidelines before submitting a PR.
Related
- Laravel Query Gate - The Laravel package this SDK is built for
