laravel-query-gate-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,689 @@
1
+ # Laravel Query Gate SDK
2
+
3
+ A contract-driven TypeScript SDK for [Laravel Query Gate](https://github.com/behindSolution/laravel-query-gate) that provides strongly-typed API interactions with compile-time safety.
4
+
5
+ ## Features
6
+
7
+ - **Contract-Driven**: One contract per resource defines all operations
8
+ - **Type-Safe**: Full TypeScript support with compile-time validation
9
+ - **Fluent API**: Chainable, immutable builder pattern
10
+ - **Laravel-Native Error Handling**: Built-in support for all Laravel error responses
11
+ - **Zero Runtime Overhead**: Contracts exist only for TypeScript, no reflection
12
+ - **Framework Agnostic**: Works with any frontend framework or Node.js
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install laravel-query-gate-sdk
18
+ ```
19
+
20
+ ```bash
21
+ yarn add laravel-query-gate-sdk
22
+ ```
23
+
24
+ ```bash
25
+ pnpm add laravel-query-gate-sdk
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```typescript
31
+ import { queryGate, configureQueryGate } from 'laravel-query-gate-sdk'
32
+
33
+ // 1. Configure the SDK
34
+ configureQueryGate({
35
+ baseUrl: 'https://api.example.com',
36
+ defaultHeaders: {
37
+ Authorization: 'Bearer your-token',
38
+ },
39
+ })
40
+
41
+ // 2. Define your contract
42
+ interface PostContract {
43
+ get: Post[]
44
+ create: CreatePostPayload
45
+ update: UpdatePostPayload
46
+ }
47
+
48
+ // 3. Use the SDK
49
+ const posts = await queryGate<PostContract>('posts')
50
+ .filter('status', 'eq', 'published')
51
+ .get()
52
+ ```
53
+
54
+ ## Table of Contents
55
+
56
+ - [Configuration](#configuration)
57
+ - [Defining Contracts](#defining-contracts)
58
+ - [Read Operations](#read-operations)
59
+ - [Write Operations](#write-operations)
60
+ - [Custom Actions](#custom-actions)
61
+ - [Query Builder](#query-builder)
62
+ - [Versioning](#versioning)
63
+ - [Headers & Options](#headers--options)
64
+ - [Error Handling](#error-handling)
65
+ - [API Reference](#api-reference)
66
+
67
+ ## Configuration
68
+
69
+ ### Global Configuration
70
+
71
+ Configure the SDK once at application startup:
72
+
73
+ ```typescript
74
+ import { configureQueryGate } from 'laravel-query-gate-sdk'
75
+
76
+ configureQueryGate({
77
+ baseUrl: 'https://api.example.com',
78
+ defaultHeaders: {
79
+ 'Authorization': 'Bearer token',
80
+ 'X-Tenant-ID': 'tenant-1',
81
+ },
82
+ defaultFetchOptions: {
83
+ credentials: 'include',
84
+ mode: 'cors',
85
+ },
86
+ })
87
+ ```
88
+
89
+ ### Isolated Instances
90
+
91
+ For multi-tenant applications or testing, create isolated instances:
92
+
93
+ ```typescript
94
+ import { createQueryGate } from 'laravel-query-gate-sdk'
95
+
96
+ const tenantApi = createQueryGate({
97
+ baseUrl: 'https://tenant1.api.example.com',
98
+ })
99
+
100
+ const posts = await tenantApi<PostContract>('posts').get()
101
+ ```
102
+
103
+ ### Configuration Options
104
+
105
+ | Option | Type | Description |
106
+ |--------|------|-------------|
107
+ | `baseUrl` | `string` | **Required.** Base URL for all API requests |
108
+ | `defaultHeaders` | `Record<string, string>` | Headers included in every request |
109
+ | `defaultFetchOptions` | `Partial<RequestInit>` | Default fetch options (credentials, mode, etc.) |
110
+
111
+ ## Defining Contracts
112
+
113
+ Contracts define the shape of your API resources. Each resource has one contract that describes:
114
+
115
+ - **Read operations** (`get`)
116
+ - **Write operations** (`create`, `update`)
117
+ - **Custom actions** (`actions`)
118
+
119
+ ### Basic Contract
120
+
121
+ ```typescript
122
+ import type { ResourceContract } from 'laravel-query-gate-sdk'
123
+
124
+ interface Post {
125
+ id: number
126
+ title: string
127
+ content: string
128
+ status: 'draft' | 'published'
129
+ created_at: string
130
+ }
131
+
132
+ interface CreatePostPayload {
133
+ title: string
134
+ content: string
135
+ }
136
+
137
+ interface UpdatePostPayload {
138
+ title?: string
139
+ content?: string
140
+ status?: 'draft' | 'published'
141
+ }
142
+
143
+ interface PostContract extends ResourceContract {
144
+ get: Post[]
145
+ create: CreatePostPayload
146
+ update: UpdatePostPayload
147
+ }
148
+ ```
149
+
150
+ ### Contract with Custom Actions
151
+
152
+ ```typescript
153
+ interface PostContract extends ResourceContract {
154
+ get: Post[]
155
+ create: CreatePostPayload
156
+ update: UpdatePostPayload
157
+
158
+ actions: {
159
+ // Action without payload
160
+ publish: {
161
+ method: 'post'
162
+ payload?: never
163
+ response: Post
164
+ }
165
+
166
+ // Action with payload
167
+ bulkPublish: {
168
+ method: 'post'
169
+ payload: { ids: number[] }
170
+ response: { updated: number }
171
+ }
172
+
173
+ // GET action
174
+ stats: {
175
+ method: 'get'
176
+ payload?: never
177
+ response: { total: number; published: number }
178
+ }
179
+
180
+ // Search action
181
+ search: {
182
+ method: 'post'
183
+ payload: { query: string; filters?: Record<string, unknown> }
184
+ response: Post[]
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ ### Read-Only Contract
191
+
192
+ If a resource only supports reading:
193
+
194
+ ```typescript
195
+ interface ReadOnlyPostContract extends ResourceContract {
196
+ get: Post[]
197
+ // No create or update - those methods will be hidden by TypeScript
198
+ }
199
+ ```
200
+
201
+ ## Read Operations
202
+
203
+ ### Fetch Collection
204
+
205
+ ```typescript
206
+ const posts = await queryGate<PostContract>('posts').get()
207
+ // posts: Post[]
208
+ ```
209
+
210
+ ### Fetch Single Resource
211
+
212
+ ```typescript
213
+ const post = await queryGate<PostContract>('posts').id(1).get()
214
+ // post: Post
215
+ ```
216
+
217
+ ### With Query Parameters
218
+
219
+ ```typescript
220
+ const posts = await queryGate<PostContract>('posts')
221
+ .filter('status', 'eq', 'published')
222
+ .filter('author_id', 'eq', 1)
223
+ .sort('created_at', 'desc')
224
+ .page(1)
225
+ .perPage(10)
226
+ .get()
227
+ ```
228
+
229
+ ## Write Operations
230
+
231
+ ### Create (POST)
232
+
233
+ ```typescript
234
+ const newPost = await queryGate<PostContract>('posts').post({
235
+ title: 'My New Post',
236
+ content: 'Hello, world!',
237
+ })
238
+ // newPost: Post
239
+ ```
240
+
241
+ ### Update (PATCH)
242
+
243
+ ```typescript
244
+ const updatedPost = await queryGate<PostContract>('posts')
245
+ .id(1)
246
+ .patch({
247
+ title: 'Updated Title',
248
+ status: 'published',
249
+ })
250
+ // updatedPost: Post
251
+ ```
252
+
253
+ ### Delete
254
+
255
+ ```typescript
256
+ await queryGate<PostContract>('posts').id(1).delete()
257
+ ```
258
+
259
+ ## Custom Actions
260
+
261
+ Custom actions allow you to call non-CRUD endpoints defined in your Laravel Query Gate resource.
262
+
263
+ ### Action Without Payload
264
+
265
+ ```typescript
266
+ // POST /posts/1/publish
267
+ const publishedPost = await queryGate<PostContract>('posts')
268
+ .id(1)
269
+ .action('publish')
270
+ .post()
271
+ // publishedPost: Post
272
+ ```
273
+
274
+ ### Action With Payload
275
+
276
+ ```typescript
277
+ // POST /posts/bulk-publish
278
+ const result = await queryGate<PostContract>('posts')
279
+ .action('bulkPublish')
280
+ .post({ ids: [1, 2, 3, 4, 5] })
281
+ // result: { updated: number }
282
+ ```
283
+
284
+ ### GET Action
285
+
286
+ ```typescript
287
+ // GET /posts/stats
288
+ const stats = await queryGate<PostContract>('posts')
289
+ .action('stats')
290
+ .get()
291
+ // stats: { total: number; published: number }
292
+ ```
293
+
294
+ ### Type Safety
295
+
296
+ TypeScript enforces correct usage:
297
+
298
+ ```typescript
299
+ // Error: payload not allowed for 'publish' action
300
+ await queryGate<PostContract>('posts')
301
+ .id(1)
302
+ .action('publish')
303
+ .post({ foo: 'bar' }) // TypeScript error!
304
+
305
+ // Error: payload required for 'bulkPublish' action
306
+ await queryGate<PostContract>('posts')
307
+ .action('bulkPublish')
308
+ .post() // TypeScript error!
309
+ ```
310
+
311
+ ## Query Builder
312
+
313
+ ### Filters
314
+
315
+ ```typescript
316
+ queryGate<PostContract>('posts')
317
+ .filter('status', 'eq', 'published')
318
+ .filter('views', 'gte', 100)
319
+ .filter('category_id', 'in', [1, 2, 3])
320
+ .get()
321
+ // Generates: ?filter[status][eq]=published&filter[views][gte]=100&filter[category_id][in]=1,2,3
322
+ ```
323
+
324
+ **Filtering on Relations:**
325
+
326
+ ```typescript
327
+ queryGate<PostContract>('posts')
328
+ .filter('author.name', 'like', 'John')
329
+ .filter('category.is_active', 'eq', 1)
330
+ .get()
331
+ // Generates: ?filter[author.name][like]=John&filter[category.is_active][eq]=1
332
+ ```
333
+
334
+ **Available Operators:**
335
+
336
+ | Operator | Description |
337
+ |----------|-------------|
338
+ | `eq` | Equal to |
339
+ | `neq` | Not equal to |
340
+ | `gt` | Greater than |
341
+ | `gte` | Greater or equal |
342
+ | `lt` | Less than |
343
+ | `lte` | Less or equal |
344
+ | `in` | In array |
345
+ | `not_in` | Not in array |
346
+ | `like` | Pattern match |
347
+ | `between` | Range (two values) |
348
+
349
+ ### Sorting
350
+
351
+ ```typescript
352
+ queryGate<PostContract>('posts')
353
+ .sort('created_at', 'desc')
354
+ .sort('title', 'asc')
355
+ .get()
356
+ // Generates: ?sort=created_at:desc,title:asc
357
+ ```
358
+
359
+ ### Pagination
360
+
361
+ ```typescript
362
+ queryGate<PostContract>('posts')
363
+ .page(2)
364
+ .perPage(25)
365
+ .get()
366
+ ```
367
+
368
+ ## Versioning
369
+
370
+ Laravel Query Gate supports API versioning via the `X-Query-Version` header:
371
+
372
+ ```typescript
373
+ const posts = await queryGate<PostContract>('posts')
374
+ .version('2.0')
375
+ .get()
376
+ ```
377
+
378
+ ## Headers & Options
379
+
380
+ ### Single Header
381
+
382
+ ```typescript
383
+ queryGate<PostContract>('posts')
384
+ .header('X-Custom-Header', 'value')
385
+ .get()
386
+ ```
387
+
388
+ ### Multiple Headers
389
+
390
+ ```typescript
391
+ queryGate<PostContract>('posts')
392
+ .headers({
393
+ 'X-Custom-Header': 'value',
394
+ 'X-Another-Header': 'another-value',
395
+ })
396
+ .get()
397
+ ```
398
+
399
+ ### Fetch Options
400
+
401
+ ```typescript
402
+ const controller = new AbortController()
403
+
404
+ queryGate<PostContract>('posts')
405
+ .options({
406
+ signal: controller.signal,
407
+ credentials: 'include',
408
+ mode: 'cors',
409
+ cache: 'no-cache',
410
+ })
411
+ .get()
412
+ ```
413
+
414
+ ## Error Handling
415
+
416
+ The SDK provides specific error classes for all common Laravel error responses.
417
+
418
+ ### Error Types
419
+
420
+ | Status | Error Class | Laravel Context |
421
+ |--------|-------------|-----------------|
422
+ | 401 | `QueryGateUnauthorizedError` | Auth middleware |
423
+ | 403 | `QueryGateForbiddenError` | Policy/Gate denial |
424
+ | 404 | `QueryGateNotFoundError` | Model not found |
425
+ | 419 | `QueryGateCsrfMismatchError` | CSRF token invalid/expired |
426
+ | 422 | `QueryGateValidationError` | Form Request validation |
427
+ | 429 | `QueryGateRateLimitError` | Throttle middleware |
428
+ | 500 | `QueryGateServerError` | Internal server error |
429
+ | 503 | `QueryGateServiceUnavailableError` | Maintenance mode |
430
+ | - | `QueryGateNetworkError` | Connection failed |
431
+ | - | `QueryGateHttpError` | Other HTTP errors |
432
+
433
+ ### Type Guards
434
+
435
+ ```typescript
436
+ import {
437
+ isUnauthorizedError,
438
+ isForbiddenError,
439
+ isNotFoundError,
440
+ isCsrfMismatchError,
441
+ isValidationError,
442
+ isRateLimitError,
443
+ isServerError,
444
+ isServiceUnavailableError,
445
+ isNetworkError,
446
+ isHttpError,
447
+ } from 'laravel-query-gate-sdk'
448
+ ```
449
+
450
+ ### Complete Error Handling Example
451
+
452
+ ```typescript
453
+ import {
454
+ queryGate,
455
+ isUnauthorizedError,
456
+ isForbiddenError,
457
+ isNotFoundError,
458
+ isCsrfMismatchError,
459
+ isValidationError,
460
+ isRateLimitError,
461
+ isServerError,
462
+ isServiceUnavailableError,
463
+ isNetworkError,
464
+ } from 'laravel-query-gate-sdk'
465
+
466
+ try {
467
+ await queryGate<PostContract>('posts').post({
468
+ title: 'New Post',
469
+ content: 'Content here',
470
+ })
471
+ } catch (error) {
472
+ // 401 - Unauthorized
473
+ if (isUnauthorizedError(error)) {
474
+ console.error('Please log in')
475
+ redirectToLogin()
476
+ return
477
+ }
478
+
479
+ // 403 - Forbidden
480
+ if (isForbiddenError(error)) {
481
+ console.error('You do not have permission')
482
+ return
483
+ }
484
+
485
+ // 404 - Not Found
486
+ if (isNotFoundError(error)) {
487
+ console.error('Resource not found')
488
+ return
489
+ }
490
+
491
+ // 419 - CSRF Token Mismatch
492
+ if (isCsrfMismatchError(error)) {
493
+ console.error('Session expired. Please refresh.')
494
+ refreshCsrfToken()
495
+ return
496
+ }
497
+
498
+ // 422 - Validation Error
499
+ if (isValidationError(error)) {
500
+ console.error('Validation failed:', error.originalMessage)
501
+
502
+ // Access all errors
503
+ console.error('Errors:', error.errors)
504
+ // { title: ['The title field is required.'], ... }
505
+
506
+ // Check specific field
507
+ if (error.hasFieldError('title')) {
508
+ console.error('Title error:', error.getFirstFieldError('title'))
509
+ }
510
+
511
+ // Get all fields with errors
512
+ console.error('Fields with errors:', error.getErrorFields())
513
+ return
514
+ }
515
+
516
+ // 429 - Rate Limit
517
+ if (isRateLimitError(error)) {
518
+ console.error('Too many requests')
519
+
520
+ if (error.hasRetryAfter()) {
521
+ console.error(`Retry after ${error.retryAfter} seconds`)
522
+ const retryDate = error.getRetryDate()
523
+ console.error(`Retry at: ${retryDate?.toLocaleTimeString()}`)
524
+ }
525
+ return
526
+ }
527
+
528
+ // 500 - Server Error
529
+ if (isServerError(error)) {
530
+ console.error('Server error. Please try again later.')
531
+ reportToErrorTracking(error)
532
+ return
533
+ }
534
+
535
+ // 503 - Service Unavailable (Maintenance)
536
+ if (isServiceUnavailableError(error)) {
537
+ console.error('Service temporarily unavailable')
538
+
539
+ if (error.hasRetryAfter()) {
540
+ console.error(`Back in approximately ${error.retryAfter} seconds`)
541
+ }
542
+ return
543
+ }
544
+
545
+ // Network Error
546
+ if (isNetworkError(error)) {
547
+ console.error('Network error:', error.originalError.message)
548
+ console.error('Please check your internet connection')
549
+ return
550
+ }
551
+
552
+ // Unknown error
553
+ throw error
554
+ }
555
+ ```
556
+
557
+ ### Validation Error Details
558
+
559
+ The `QueryGateValidationError` provides helper methods for working with Laravel's validation error format:
560
+
561
+ ```typescript
562
+ if (isValidationError(error)) {
563
+ // Get all errors for a field
564
+ const titleErrors: string[] = error.getFieldErrors('title')
565
+ // ['The title field is required.', 'The title must be at least 3 characters.']
566
+
567
+ // Get first error for a field
568
+ const firstError: string | undefined = error.getFirstFieldError('title')
569
+ // 'The title field is required.'
570
+
571
+ // Check if field has errors
572
+ const hasError: boolean = error.hasFieldError('title')
573
+ // true
574
+
575
+ // Get all fields with errors
576
+ const fields: string[] = error.getErrorFields()
577
+ // ['title', 'content', 'author_id']
578
+
579
+ // Access raw errors object
580
+ const rawErrors: Record<string, string[]> = error.errors
581
+ // { title: [...], content: [...] }
582
+
583
+ // Access original Laravel message
584
+ const message: string = error.originalMessage
585
+ // 'The given data was invalid.'
586
+ }
587
+ ```
588
+
589
+ ### Rate Limit & Service Unavailable Retry-After
590
+
591
+ Both `QueryGateRateLimitError` (429) and `QueryGateServiceUnavailableError` (503) support the `Retry-After` header:
592
+
593
+ ```typescript
594
+ if (isRateLimitError(error) || isServiceUnavailableError(error)) {
595
+ // Check if retry information is available
596
+ if (error.hasRetryAfter()) {
597
+ // Get seconds until retry
598
+ const seconds: number | null = error.retryAfter
599
+ // 60
600
+
601
+ // Get Date object for when retry is allowed
602
+ const retryDate: Date | null = error.getRetryDate()
603
+ // Date object
604
+ }
605
+ }
606
+ ```
607
+
608
+ ## API Reference
609
+
610
+ ### Entry Points
611
+
612
+ #### `configureQueryGate(config: QueryGateConfig): void`
613
+
614
+ Configure the SDK globally.
615
+
616
+ #### `queryGate<T>(resource: string): QueryGateBuilder<T>`
617
+
618
+ Create a builder for a resource using global configuration.
619
+
620
+ #### `createQueryGate(config: QueryGateConfig): <T>(resource: string) => QueryGateBuilder<T>`
621
+
622
+ Create an isolated SDK instance with its own configuration.
623
+
624
+ ### QueryGateBuilder Methods
625
+
626
+ | Method | Description |
627
+ |--------|-------------|
628
+ | `.id(id)` | Set resource ID for single resource operations |
629
+ | `.filter(field, operator, value)` | Add a filter |
630
+ | `.sort(field, direction?)` | Add sorting (default: 'asc') |
631
+ | `.page(page)` | Set page number |
632
+ | `.perPage(count)` | Set items per page |
633
+ | `.version(version)` | Set API version header |
634
+ | `.header(key, value)` | Add a single header |
635
+ | `.headers(record)` | Add multiple headers |
636
+ | `.options(fetchOptions)` | Set fetch options |
637
+ | `.get()` | Execute GET request |
638
+ | `.post(payload)` | Execute POST request |
639
+ | `.action(name)` | Access custom action |
640
+
641
+ ### QueryGateBuilderWithId Methods
642
+
643
+ Available after calling `.id()`:
644
+
645
+ | Method | Description |
646
+ |--------|-------------|
647
+ | `.get()` | Fetch single resource |
648
+ | `.patch(payload)` | Update resource |
649
+ | `.delete()` | Delete resource |
650
+ | `.action(name)` | Execute action on resource |
651
+
652
+ ### Types
653
+
654
+ ```typescript
655
+ import type {
656
+ ResourceContract,
657
+ ActionContract,
658
+ FilterOperator,
659
+ SortDirection,
660
+ QueryGateConfig,
661
+ ValidationErrors,
662
+ } from 'laravel-query-gate-sdk'
663
+ ```
664
+
665
+ ## Design Philosophy
666
+
667
+ The SDK follows these principles:
668
+
669
+ 1. **Explicit contracts over magic** - All behavior is declared in contracts
670
+ 2. **Compile-time safety over runtime guessing** - TypeScript catches errors before runtime
671
+ 3. **Orchestration over domain logic** - SDK is a transport layer only
672
+ 4. **Consistency over brevity** - Predictable API surface
673
+
674
+ ## Requirements
675
+
676
+ - Node.js 18+
677
+ - TypeScript 5.0+ (recommended)
678
+
679
+ ## License
680
+
681
+ MIT
682
+
683
+ ## Contributing
684
+
685
+ Contributions are welcome! Please read the contributing guidelines before submitting a PR.
686
+
687
+ ## Related
688
+
689
+ - [Laravel Query Gate](https://github.com/behindSolution/laravel-query-gate) - The Laravel package this SDK is built for