@schafevormfenster/rest-commons 0.1.1
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/CONTRIBUTING.md +1190 -0
- package/README.md +275 -0
- package/bin/setup.js +10 -0
- package/dist/api-schemas/error.schema.d.ts +20 -0
- package/dist/api-schemas/error.schema.d.ts.map +1 -0
- package/dist/api-schemas/error.schema.js +17 -0
- package/dist/api-schemas/health.schema.d.ts +497 -0
- package/dist/api-schemas/health.schema.d.ts.map +1 -0
- package/dist/api-schemas/health.schema.js +33 -0
- package/dist/api-schemas/okay.schema.d.ts +13 -0
- package/dist/api-schemas/okay.schema.d.ts.map +1 -0
- package/dist/api-schemas/okay.schema.js +5 -0
- package/dist/api-schemas/paginated-results.schema.d.ts +59 -0
- package/dist/api-schemas/paginated-results.schema.d.ts.map +1 -0
- package/dist/api-schemas/paginated-results.schema.js +10 -0
- package/dist/api-schemas/partial-results.schema.d.ts +30 -0
- package/dist/api-schemas/partial-results.schema.d.ts.map +1 -0
- package/dist/api-schemas/partial-results.schema.js +10 -0
- package/dist/api-schemas/result.schema.d.ts +17 -0
- package/dist/api-schemas/result.schema.d.ts.map +1 -0
- package/dist/api-schemas/result.schema.js +5 -0
- package/dist/api-schemas/results.schema.d.ts +21 -0
- package/dist/api-schemas/results.schema.d.ts.map +1 -0
- package/dist/api-schemas/results.schema.js +5 -0
- package/dist/helpers/correlation/get-correlation-id.d.ts +7 -0
- package/dist/helpers/correlation/get-correlation-id.d.ts.map +1 -0
- package/dist/helpers/correlation/get-correlation-id.js +16 -0
- package/dist/helpers/correlation/get-header.d.ts +7 -0
- package/dist/helpers/correlation/get-header.d.ts.map +1 -0
- package/dist/helpers/correlation/get-header.js +11 -0
- package/dist/helpers/detect-mime-type.d.ts +11 -0
- package/dist/helpers/detect-mime-type.d.ts.map +1 -0
- package/dist/helpers/detect-mime-type.js +40 -0
- package/dist/helpers/detect-suspicious-patterns.d.ts +8 -0
- package/dist/helpers/detect-suspicious-patterns.d.ts.map +1 -0
- package/dist/helpers/detect-suspicious-patterns.js +55 -0
- package/dist/helpers/eventify-constants.types.d.ts +32 -0
- package/dist/helpers/eventify-constants.types.d.ts.map +1 -0
- package/dist/helpers/eventify-constants.types.js +40 -0
- package/dist/helpers/hash-binary.d.ts +21 -0
- package/dist/helpers/hash-binary.d.ts.map +1 -0
- package/dist/helpers/hash-binary.js +28 -0
- package/dist/helpers/mime-types/detect-image-mime-type.d.ts +5 -0
- package/dist/helpers/mime-types/detect-image-mime-type.d.ts.map +1 -0
- package/dist/helpers/mime-types/detect-image-mime-type.js +41 -0
- package/dist/helpers/mime-types/detect-ole-mime-type.d.ts +6 -0
- package/dist/helpers/mime-types/detect-ole-mime-type.d.ts.map +1 -0
- package/dist/helpers/mime-types/detect-ole-mime-type.js +34 -0
- package/dist/helpers/mime-types/detect-pdf-mime-type.d.ts +5 -0
- package/dist/helpers/mime-types/detect-pdf-mime-type.d.ts.map +1 -0
- package/dist/helpers/mime-types/detect-pdf-mime-type.js +13 -0
- package/dist/helpers/mime-types/detect-zip-mime-type.d.ts +6 -0
- package/dist/helpers/mime-types/detect-zip-mime-type.d.ts.map +1 -0
- package/dist/helpers/mime-types/detect-zip-mime-type.js +23 -0
- package/dist/helpers/parameter-validation.d.ts +6 -0
- package/dist/helpers/parameter-validation.d.ts.map +1 -0
- package/dist/helpers/parameter-validation.js +19 -0
- package/dist/helpers/parameter-validation.types.d.ts +16 -0
- package/dist/helpers/parameter-validation.types.d.ts.map +1 -0
- package/dist/helpers/parameter-validation.types.js +38 -0
- package/dist/helpers/response-headers/build-api-unauthorized-headers.d.ts +6 -0
- package/dist/helpers/response-headers/build-api-unauthorized-headers.d.ts.map +1 -0
- package/dist/helpers/response-headers/build-api-unauthorized-headers.js +23 -0
- package/dist/helpers/response-headers/environment.types.d.ts +2 -0
- package/dist/helpers/response-headers/environment.types.d.ts.map +1 -0
- package/dist/helpers/response-headers/environment.types.js +1 -0
- package/dist/helpers/response-headers/resolve-environment.d.ts +8 -0
- package/dist/helpers/response-headers/resolve-environment.d.ts.map +1 -0
- package/dist/helpers/response-headers/resolve-environment.js +18 -0
- package/dist/helpers/slugify.d.ts +15 -0
- package/dist/helpers/slugify.d.ts.map +1 -0
- package/dist/helpers/slugify.js +32 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/normalization/normalize-list.d.ts +11 -0
- package/dist/normalization/normalize-list.d.ts.map +1 -0
- package/dist/normalization/normalize-list.js +19 -0
- package/dist/normalization/normalize-location.d.ts +16 -0
- package/dist/normalization/normalize-location.d.ts.map +1 -0
- package/dist/normalization/normalize-location.js +26 -0
- package/dist/primitives/coordinate-precision.d.ts +10 -0
- package/dist/primitives/coordinate-precision.d.ts.map +1 -0
- package/dist/primitives/coordinate-precision.js +27 -0
- package/dist/primitives/geo-point.schema.d.ts +8 -0
- package/dist/primitives/geo-point.schema.d.ts.map +1 -0
- package/dist/primitives/geo-point.schema.js +10 -0
- package/dist/primitives/geoname-id.schema.d.ts +8 -0
- package/dist/primitives/geoname-id.schema.d.ts.map +1 -0
- package/dist/primitives/geoname-id.schema.js +9 -0
- package/dist/primitives/international-zip.schema.d.ts +76 -0
- package/dist/primitives/international-zip.schema.d.ts.map +1 -0
- package/dist/primitives/international-zip.schema.js +81 -0
- package/dist/primitives/latitude.schema.d.ts +9 -0
- package/dist/primitives/latitude.schema.d.ts.map +1 -0
- package/dist/primitives/latitude.schema.js +13 -0
- package/dist/primitives/location.schema.d.ts +8 -0
- package/dist/primitives/location.schema.d.ts.map +1 -0
- package/dist/primitives/location.schema.js +15 -0
- package/dist/primitives/longitude.schema.d.ts +9 -0
- package/dist/primitives/longitude.schema.d.ts.map +1 -0
- package/dist/primitives/longitude.schema.js +13 -0
- package/dist/primitives/numeric-id.schema.d.ts +8 -0
- package/dist/primitives/numeric-id.schema.d.ts.map +1 -0
- package/dist/primitives/numeric-id.schema.js +10 -0
- package/dist/primitives/slug.schema.d.ts +17 -0
- package/dist/primitives/slug.schema.d.ts.map +1 -0
- package/dist/primitives/slug.schema.js +30 -0
- package/dist/primitives/uuid.schema.d.ts +8 -0
- package/dist/primitives/uuid.schema.d.ts.map +1 -0
- package/dist/primitives/uuid.schema.js +9 -0
- package/dist/primitives/wikidata-id.schema.d.ts +9 -0
- package/dist/primitives/wikidata-id.schema.d.ts.map +1 -0
- package/dist/primitives/wikidata-id.schema.js +10 -0
- package/dist/time/boundary-enforcement.d.ts +11 -0
- package/dist/time/boundary-enforcement.d.ts.map +1 -0
- package/dist/time/boundary-enforcement.js +43 -0
- package/dist/time/bounded-time.schema.d.ts +31 -0
- package/dist/time/bounded-time.schema.d.ts.map +1 -0
- package/dist/time/bounded-time.schema.js +77 -0
- package/dist/time/flexible-time-parser.d.ts +12 -0
- package/dist/time/flexible-time-parser.d.ts.map +1 -0
- package/dist/time/flexible-time-parser.js +94 -0
- package/dist/time/flexible-time.schema.d.ts +31 -0
- package/dist/time/flexible-time.schema.d.ts.map +1 -0
- package/dist/time/flexible-time.schema.js +31 -0
- package/dist/time/get-week-end.d.ts +10 -0
- package/dist/time/get-week-end.d.ts.map +1 -0
- package/dist/time/get-week-end.js +25 -0
- package/dist/time/get-week-start.d.ts +10 -0
- package/dist/time/get-week-start.d.ts.map +1 -0
- package/dist/time/get-week-start.js +25 -0
- package/dist/time/is-relative-time.d.ts +8 -0
- package/dist/time/is-relative-time.d.ts.map +1 -0
- package/dist/time/is-relative-time.js +9 -0
- package/dist/time/iso8601.schema.d.ts +14 -0
- package/dist/time/iso8601.schema.d.ts.map +1 -0
- package/dist/time/iso8601.schema.js +17 -0
- package/dist/time/iso8601.types.d.ts +6 -0
- package/dist/time/iso8601.types.d.ts.map +1 -0
- package/dist/time/iso8601.types.js +11 -0
- package/dist/time/parse-relative-time.d.ts +9 -0
- package/dist/time/parse-relative-time.d.ts.map +1 -0
- package/dist/time/parse-relative-time.js +36 -0
- package/dist/time/relative-time.schema.d.ts +23 -0
- package/dist/time/relative-time.schema.d.ts.map +1 -0
- package/dist/time/relative-time.schema.js +25 -0
- package/dist/time/since-parameter.schema.d.ts +8 -0
- package/dist/time/since-parameter.schema.d.ts.map +1 -0
- package/dist/time/since-parameter.schema.js +56 -0
- package/dist/time/time-helpers.d.ts +19 -0
- package/dist/time/time-helpers.d.ts.map +1 -0
- package/dist/time/time-helpers.js +56 -0
- package/dist/time/time-schemas.d.ts +20 -0
- package/dist/time/time-schemas.d.ts.map +1 -0
- package/dist/time/time-schemas.js +25 -0
- package/dist/time/timezone.types.d.ts +17 -0
- package/dist/time/timezone.types.d.ts.map +1 -0
- package/dist/time/timezone.types.js +15 -0
- package/dist/validation/zod-error-handler.d.ts +3 -0
- package/dist/validation/zod-error-handler.d.ts.map +1 -0
- package/dist/validation/zod-error-handler.js +189 -0
- package/dist/validation/zod-utils.d.ts +9 -0
- package/dist/validation/zod-utils.d.ts.map +1 -0
- package/dist/validation/zod-utils.js +23 -0
- package/eslint.config.mjs +16 -0
- package/package.json +44 -0
- package/src/api-schemas/error.schema.test.ts +27 -0
- package/src/api-schemas/error.schema.ts +23 -0
- package/src/api-schemas/health.schema.test.ts +104 -0
- package/src/api-schemas/health.schema.ts +63 -0
- package/src/api-schemas/okay.schema.test.ts +15 -0
- package/src/api-schemas/okay.schema.ts +8 -0
- package/src/api-schemas/paginated-results.schema.ts +17 -0
- package/src/api-schemas/partial-results.schema.ts +13 -0
- package/src/api-schemas/result.schema.test.ts +19 -0
- package/src/api-schemas/result.schema.ts +9 -0
- package/src/api-schemas/results.schema.test.ts +15 -0
- package/src/api-schemas/results.schema.ts +9 -0
- package/src/helpers/correlation/get-correlation-id.test.ts +126 -0
- package/src/helpers/correlation/get-correlation-id.ts +22 -0
- package/src/helpers/correlation/get-header.test.ts +179 -0
- package/src/helpers/correlation/get-header.ts +21 -0
- package/src/helpers/detect-mime-type.test.ts +100 -0
- package/src/helpers/detect-mime-type.ts +46 -0
- package/src/helpers/detect-suspicious-patterns.test.ts +45 -0
- package/src/helpers/detect-suspicious-patterns.ts +57 -0
- package/src/helpers/eventify-constants.test.ts +52 -0
- package/src/helpers/eventify-constants.types.test.ts +52 -0
- package/src/helpers/eventify-constants.types.ts +51 -0
- package/src/helpers/hash-binary.test.ts +60 -0
- package/src/helpers/hash-binary.ts +30 -0
- package/src/helpers/mime-types/detect-image-mime-type.test.ts +73 -0
- package/src/helpers/mime-types/detect-image-mime-type.ts +50 -0
- package/src/helpers/mime-types/detect-ole-mime-type.test.ts +86 -0
- package/src/helpers/mime-types/detect-ole-mime-type.ts +44 -0
- package/src/helpers/mime-types/detect-pdf-mime-type.test.ts +39 -0
- package/src/helpers/mime-types/detect-pdf-mime-type.ts +15 -0
- package/src/helpers/mime-types/detect-zip-mime-type.test.ts +88 -0
- package/src/helpers/mime-types/detect-zip-mime-type.ts +28 -0
- package/src/helpers/parameter-validation.test.ts +35 -0
- package/src/helpers/parameter-validation.ts +32 -0
- package/src/helpers/process-eventify-request.ts +146 -0
- package/src/helpers/response-headers/build-api-unauthorized-headers.ts +30 -0
- package/src/helpers/response-headers/environment.types.ts +1 -0
- package/src/helpers/response-headers/resolve-environment.ts +17 -0
- package/src/helpers/slugify.test.ts +77 -0
- package/src/helpers/slugify.ts +34 -0
- package/src/index.ts +46 -0
- package/src/normalization/normalize-list.test.ts +43 -0
- package/src/normalization/normalize-list.ts +21 -0
- package/src/normalization/normalize-location.test.ts +91 -0
- package/src/normalization/normalize-location.ts +29 -0
- package/src/primitives/coordinate-precision.test.ts +46 -0
- package/src/primitives/coordinate-precision.ts +30 -0
- package/src/primitives/geo-point.schema.test.ts +70 -0
- package/src/primitives/geo-point.schema.ts +14 -0
- package/src/primitives/geoname-id.schema.test.ts +60 -0
- package/src/primitives/geoname-id.schema.ts +12 -0
- package/src/primitives/international-zip.schema.test.ts +212 -0
- package/src/primitives/international-zip.schema.ts +103 -0
- package/src/primitives/latitude.schema.test.ts +77 -0
- package/src/primitives/latitude.schema.ts +20 -0
- package/src/primitives/location.schema.test.ts +21 -0
- package/src/primitives/location.schema.ts +22 -0
- package/src/primitives/longitude.schema.test.ts +77 -0
- package/src/primitives/longitude.schema.ts +20 -0
- package/src/primitives/numeric-id.schema.test.ts +32 -0
- package/src/primitives/numeric-id.schema.ts +13 -0
- package/src/primitives/slug.schema.test.ts +101 -0
- package/src/primitives/slug.schema.ts +41 -0
- package/src/primitives/uuid.schema.test.ts +45 -0
- package/src/primitives/uuid.schema.ts +12 -0
- package/src/primitives/wikidata-id.schema.test.ts +51 -0
- package/src/primitives/wikidata-id.schema.ts +16 -0
- package/src/time/README.md +220 -0
- package/src/time/boundary-enforcement.test.ts +130 -0
- package/src/time/boundary-enforcement.ts +59 -0
- package/src/time/bounded-time.schema.test.ts +294 -0
- package/src/time/bounded-time.schema.ts +111 -0
- package/src/time/flexible-time-parser.test.ts +586 -0
- package/src/time/flexible-time-parser.ts +122 -0
- package/src/time/flexible-time.schema.test.ts +243 -0
- package/src/time/flexible-time.schema.ts +43 -0
- package/src/time/is-relative-time.test.ts +23 -0
- package/src/time/is-relative-time.ts +9 -0
- package/src/time/iso8601.schema.ts +29 -0
- package/src/time/iso8601.types.test.ts +112 -0
- package/src/time/iso8601.types.ts +21 -0
- package/src/time/parse-relative-time.test.ts +49 -0
- package/src/time/parse-relative-time.ts +50 -0
- package/src/time/relative-time.schema.test.ts +23 -0
- package/src/time/relative-time.schema.ts +38 -0
- package/src/time/since-parameter.schema.test.ts +59 -0
- package/src/time/since-parameter.schema.ts +69 -0
- package/src/time/time-helpers.test.ts +263 -0
- package/src/time/time-helpers.ts +78 -0
- package/src/time/time-schemas.test.ts +181 -0
- package/src/time/time-schemas.ts +42 -0
- package/src/time/time.schema.test.ts +237 -0
- package/src/time/timezone-independence.test.ts +188 -0
- package/src/time/timezone.types.test.ts +55 -0
- package/src/time/timezone.types.ts +22 -0
- package/tsconfig.json +26 -0
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
# Contributing to @schafevormfenster/rest-commons
|
|
2
|
+
|
|
3
|
+
This guide explains **how to use** this package and **how to develop** proper REST API endpoints following our standards.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Quick Start](#quick-start)
|
|
8
|
+
- [Package Overview](#package-overview)
|
|
9
|
+
- [REST Service Design Patterns](#rest-service-design-patterns)
|
|
10
|
+
- [Technology Stack](#technology-stack)
|
|
11
|
+
- [Response Structure Patterns](#response-structure-patterns)
|
|
12
|
+
- [Contract-First Development](#contract-first-development)
|
|
13
|
+
- [Schema-Driven Validation](#schema-driven-validation)
|
|
14
|
+
- [Authentication Patterns](#authentication-patterns)
|
|
15
|
+
- [Caching Strategy](#caching-strategy)
|
|
16
|
+
- [Input Validation & Security](#input-validation--security)
|
|
17
|
+
- [Error Handling](#error-handling)
|
|
18
|
+
- [API Design Patterns](#api-design-patterns)
|
|
19
|
+
- [Unified GET/POST Endpoints](#unified-getpost-endpoints)
|
|
20
|
+
- [Static-First Processing](#static-first-processing)
|
|
21
|
+
- [Testing Guidelines](#testing-guidelines)
|
|
22
|
+
- [E2E Testing with Playwright](#e2e-testing-with-playwright)
|
|
23
|
+
- [Unit Testing](#unit-testing)
|
|
24
|
+
- [ESLint Rules for REST APIs](#eslint-rules-for-rest-apis)
|
|
25
|
+
- [Critical Rules](#critical-rules)
|
|
26
|
+
- [Important Rules](#important-rules)
|
|
27
|
+
- [Best Practice Rules](#best-practice-rules)
|
|
28
|
+
- [Development Workflow](#development-workflow)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
### Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm add @schafevormfenster/rest-commons
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Basic Usage
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// Import standardized response schemas
|
|
44
|
+
import {
|
|
45
|
+
ResultSchema,
|
|
46
|
+
ResultsSchema,
|
|
47
|
+
ErrorSchema,
|
|
48
|
+
HealthSchema,
|
|
49
|
+
OkaySchema,
|
|
50
|
+
} from "@schafevormfenster/rest-commons";
|
|
51
|
+
|
|
52
|
+
// Import primitive schemas
|
|
53
|
+
import {
|
|
54
|
+
LocationSchema,
|
|
55
|
+
SlugSchema,
|
|
56
|
+
UuidSchema,
|
|
57
|
+
} from "@schafevormfenster/rest-commons";
|
|
58
|
+
|
|
59
|
+
// Import time utilities
|
|
60
|
+
import {
|
|
61
|
+
ISO8601Schema,
|
|
62
|
+
parseRelativeTime,
|
|
63
|
+
isRelativeTime,
|
|
64
|
+
} from "@schafevormfenster/rest-commons";
|
|
65
|
+
|
|
66
|
+
// Import validation helpers
|
|
67
|
+
import {
|
|
68
|
+
detectSuspiciousPatterns,
|
|
69
|
+
normalizeList,
|
|
70
|
+
getCorrelationId,
|
|
71
|
+
} from "@schafevormfenster/rest-commons";
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Package Overview
|
|
77
|
+
|
|
78
|
+
**@schafevormfenster/rest-commons** is the centralized authority for REST API standards and schemas. It provides:
|
|
79
|
+
|
|
80
|
+
- **Standardized Response Schemas**: Consistent wrappers for single resources, collections, errors, and health checks
|
|
81
|
+
- **Primitive Schemas**: Reusable Zod schemas for common data types (UUID, slug, location, etc.)
|
|
82
|
+
- **Time Utilities**: ISO 8601 handling, relative time parsing, week boundaries
|
|
83
|
+
- **Validation Helpers**: Input sanitization, suspicious pattern detection, parameter validation
|
|
84
|
+
- **Response Utilities**: Correlation IDs, header builders, environment detection
|
|
85
|
+
|
|
86
|
+
### What This Package Contains
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
src/
|
|
90
|
+
├── api-schemas/ # Standardized response schemas
|
|
91
|
+
│ ├── result.schema.ts # Single resource response
|
|
92
|
+
│ ├── results.schema.ts # Collection response
|
|
93
|
+
│ ├── error.schema.ts # Error response
|
|
94
|
+
│ ├── health.schema.ts # Health check response
|
|
95
|
+
│ └── okay.schema.ts # Success acknowledgment
|
|
96
|
+
├── primitives/ # Reusable primitive schemas
|
|
97
|
+
│ ├── location.schema.ts
|
|
98
|
+
│ ├── slug.schema.ts
|
|
99
|
+
│ ├── uuid.schema.ts
|
|
100
|
+
│ └── ...
|
|
101
|
+
├── time/ # Time handling utilities
|
|
102
|
+
│ ├── iso8601.types.ts
|
|
103
|
+
│ ├── parse-relative-time.ts
|
|
104
|
+
│ └── ...
|
|
105
|
+
├── helpers/ # Validation and utilities
|
|
106
|
+
│ ├── detect-suspicious-patterns.ts
|
|
107
|
+
│ ├── parameter-validation.ts
|
|
108
|
+
│ ├── correlation/
|
|
109
|
+
│ └── response-headers/
|
|
110
|
+
├── normalization/ # Data normalization
|
|
111
|
+
└── validation/ # Zod utilities
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## REST Service Design Patterns
|
|
117
|
+
|
|
118
|
+
### Technology Stack
|
|
119
|
+
|
|
120
|
+
Our REST APIs are built using:
|
|
121
|
+
|
|
122
|
+
- **ts-rest**: Contract-first API development
|
|
123
|
+
- **Zod**: Runtime schema validation and type safety
|
|
124
|
+
- **Next.js**: API routes and handlers
|
|
125
|
+
- **Fetch API**: HTTP client (axios is forbidden)
|
|
126
|
+
- **date-fns**: Date handling (moment is forbidden)
|
|
127
|
+
|
|
128
|
+
⚠️ **Enforced by ESLint**: `stack/no-forbidden-imports` prevents usage of forbidden libraries.
|
|
129
|
+
|
|
130
|
+
### Response Structure Patterns
|
|
131
|
+
|
|
132
|
+
All API endpoints must use standardized response schemas from this package:
|
|
133
|
+
|
|
134
|
+
#### Single Resource Response (`ResultSchema`)
|
|
135
|
+
|
|
136
|
+
Use for endpoints that return a single resource:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { ResultSchema } from "@schafevormfenster/rest-commons";
|
|
140
|
+
|
|
141
|
+
// Define your data schema
|
|
142
|
+
const MyDataSchema = z.object({
|
|
143
|
+
id: z.string(),
|
|
144
|
+
name: z.string(),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Response schema
|
|
148
|
+
const MyResponseSchema = ResultSchema.extend({
|
|
149
|
+
data: MyDataSchema,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export type MyResponse = z.infer<typeof MyResponseSchema>;
|
|
153
|
+
|
|
154
|
+
// Returns:
|
|
155
|
+
// {
|
|
156
|
+
// status: 200,
|
|
157
|
+
// timestamp: "2024-01-15T10:30:00.000Z",
|
|
158
|
+
// data: { id: "123", name: "Example" }
|
|
159
|
+
// }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### Collection Response (`ResultsSchema`)
|
|
163
|
+
|
|
164
|
+
Use for endpoints that return multiple resources:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import { ResultsSchema } from "@schafevormfenster/rest-commons";
|
|
168
|
+
|
|
169
|
+
const MyCollectionSchema = ResultsSchema.extend({
|
|
170
|
+
data: z.array(MyDataSchema),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Returns:
|
|
174
|
+
// {
|
|
175
|
+
// status: 200,
|
|
176
|
+
// timestamp: "2024-01-15T10:30:00.000Z",
|
|
177
|
+
// data: [...]
|
|
178
|
+
// }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
#### Error Response (`ErrorSchema`)
|
|
182
|
+
|
|
183
|
+
All errors should use the standardized error schema:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import { ErrorSchema } from "@schafevormfenster/rest-commons";
|
|
187
|
+
|
|
188
|
+
// Returns:
|
|
189
|
+
// {
|
|
190
|
+
// status: 400,
|
|
191
|
+
// timestamp: "2024-01-15T10:30:00.000Z",
|
|
192
|
+
// error: "Validation error: Required field missing"
|
|
193
|
+
// }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
#### Success Response (`OkaySchema`)
|
|
197
|
+
|
|
198
|
+
For operations without data payload:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { OkaySchema } from "@schafevormfenster/rest-commons";
|
|
202
|
+
|
|
203
|
+
// Returns:
|
|
204
|
+
// {
|
|
205
|
+
// status: 200,
|
|
206
|
+
// timestamp: "2024-01-15T10:30:00.000Z",
|
|
207
|
+
// message: "Operation completed successfully"
|
|
208
|
+
// }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
#### Health Check Response (`HealthSchema`)
|
|
212
|
+
|
|
213
|
+
For service monitoring:
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
import { HealthSchema } from "@schafevormfenster/rest-commons";
|
|
217
|
+
|
|
218
|
+
// Returns:
|
|
219
|
+
// {
|
|
220
|
+
// status: 200,
|
|
221
|
+
// timestamp: "2024-01-15T10:30:00.000Z",
|
|
222
|
+
// name: "My API",
|
|
223
|
+
// version: "1.0.0",
|
|
224
|
+
// dependencies: [...]
|
|
225
|
+
// }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Contract-First Development
|
|
229
|
+
|
|
230
|
+
API contracts define the interface before implementation:
|
|
231
|
+
|
|
232
|
+
1. **Define the contract** using ts-rest
|
|
233
|
+
2. **Define Zod schemas** in `*.schema.ts` files
|
|
234
|
+
3. **Implement route handlers** using the contract
|
|
235
|
+
4. **Write tests** against the contract
|
|
236
|
+
|
|
237
|
+
⚠️ **Enforced by ESLint**: `rest/enforce-api-route-structure` ensures every route has:
|
|
238
|
+
- `route.ts` - Route handler implementation
|
|
239
|
+
- `route.test.ts` - Co-located tests
|
|
240
|
+
- `*.contract.ts` - ts-rest API contract
|
|
241
|
+
- `*.schema.ts` - Zod schemas and TypeScript types
|
|
242
|
+
|
|
243
|
+
### Schema-Driven Validation
|
|
244
|
+
|
|
245
|
+
Every Zod schema **must** have a corresponding TypeScript type export:
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
// ✅ CORRECT: Schema with TypeScript type export
|
|
249
|
+
export const UpdateRequestSchema = z.object({
|
|
250
|
+
id: z.string(),
|
|
251
|
+
name: z.string(),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
export type UpdateRequest = z.infer<typeof UpdateRequestSchema>;
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
// ❌ INCORRECT: Schema without TypeScript type export
|
|
259
|
+
export const UpdateRequestSchema = z.object({
|
|
260
|
+
id: z.string(),
|
|
261
|
+
name: z.string(),
|
|
262
|
+
});
|
|
263
|
+
// Missing: export type UpdateRequest = z.infer<typeof UpdateRequestSchema>;
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
⚠️ **Enforced by ESLint**: `rest/enforce-schema-type-export` ensures all schemas have type exports.
|
|
267
|
+
|
|
268
|
+
**Naming Convention**:
|
|
269
|
+
- Schema: `{Name}Schema` (e.g., `UpdateCalendarRequestSchema`)
|
|
270
|
+
- Type: `{Name}` (e.g., `UpdateCalendarRequest`)
|
|
271
|
+
|
|
272
|
+
**Reuse Common Schemas**:
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
// ✅ PREFERRED: Reuse standard response wrapper
|
|
276
|
+
import { ResultSchema } from "@schafevormfenster/rest-commons";
|
|
277
|
+
|
|
278
|
+
export const MyResponseSchema = ResultSchema.extend({
|
|
279
|
+
data: z.object({
|
|
280
|
+
message: z.string(),
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
// ❌ DISCOURAGED: Defining response structure from scratch
|
|
287
|
+
export const MyResponseSchema = z.object({
|
|
288
|
+
status: z.number(),
|
|
289
|
+
timestamp: z.string(),
|
|
290
|
+
data: z.object({
|
|
291
|
+
message: z.string(),
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
⚠️ **Enforced by ESLint**: `rest/prefer-schema-extension` warns when you define schemas from scratch.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Authentication Patterns
|
|
301
|
+
|
|
302
|
+
### Token-as-Path Design
|
|
303
|
+
|
|
304
|
+
All authenticated endpoints require the token as a **path parameter**, not a header:
|
|
305
|
+
|
|
306
|
+
✅ **Correct**: `POST /api/{token}/classify`
|
|
307
|
+
❌ **Incorrect**: Using `Sheep-Token` header
|
|
308
|
+
|
|
309
|
+
**Implementation Pattern**:
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
import { checkAccessToken } from "@/security/auth";
|
|
313
|
+
|
|
314
|
+
export async function POST({ params }: { params: { token: string } }) {
|
|
315
|
+
// First action: validate token
|
|
316
|
+
checkAccessToken(params.token);
|
|
317
|
+
|
|
318
|
+
// Continue with route logic...
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Error Responses**:
|
|
323
|
+
- `401 Unauthorized` - Token is missing, invalid, or not authorized
|
|
324
|
+
|
|
325
|
+
**Public Endpoints** (no token required):
|
|
326
|
+
- `GET /api/health`
|
|
327
|
+
- `GET /api/openapi`
|
|
328
|
+
- `GET /api/categories`
|
|
329
|
+
- `GET /api/scopes`
|
|
330
|
+
|
|
331
|
+
⚠️ **Enforced by ESLint**: `rest/enforce-token-in-path` ensures proper authentication patterns.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Caching Strategy
|
|
336
|
+
|
|
337
|
+
### GET Requests
|
|
338
|
+
|
|
339
|
+
All GET route handlers **must** explicitly set `Cache-Control` headers:
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
export async function GET(request: Request) {
|
|
343
|
+
const response = Response.json({ data: [] });
|
|
344
|
+
response.headers.set('Cache-Control', 'public, max-age=3600');
|
|
345
|
+
return response;
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
⚠️ **Enforced by ESLint**: `caching/enforce-semantic-cache-headers` warns if Cache-Control is missing.
|
|
350
|
+
|
|
351
|
+
### POST Requests
|
|
352
|
+
|
|
353
|
+
**Never** use `"use cache"` directive in POST handlers. Use `unstable_cache` instead:
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
// ❌ INCORRECT
|
|
357
|
+
"use cache";
|
|
358
|
+
|
|
359
|
+
export async function POST(request) {
|
|
360
|
+
// This won't work!
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
// ✅ CORRECT
|
|
366
|
+
import { unstable_cache } from 'next/cache';
|
|
367
|
+
|
|
368
|
+
export async function POST(request) {
|
|
369
|
+
const cached = await unstable_cache(
|
|
370
|
+
async () => { /* ... */ },
|
|
371
|
+
['cache-key']
|
|
372
|
+
)();
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
⚠️ **Enforced by ESLint**: `caching/no-use-cache-in-post` prevents incorrect cache usage.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## Input Validation & Security
|
|
381
|
+
|
|
382
|
+
### Suspicious Pattern Detection
|
|
383
|
+
|
|
384
|
+
Always validate input for malicious patterns:
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
import { detectSuspiciousPatterns } from "@schafevormfenster/rest-commons";
|
|
388
|
+
|
|
389
|
+
export async function POST(request: Request) {
|
|
390
|
+
const body = await request.json();
|
|
391
|
+
|
|
392
|
+
// Detect suspicious patterns
|
|
393
|
+
if (detectSuspiciousPatterns(body.userInput)) {
|
|
394
|
+
return Response.json(
|
|
395
|
+
{ status: 400, error: "Invalid input detected" },
|
|
396
|
+
{ status: 400 }
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Parameter Validation
|
|
403
|
+
|
|
404
|
+
Use helper functions for common validation:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
import { validateRequiredParams } from "@schafevormfenster/rest-commons";
|
|
408
|
+
|
|
409
|
+
export async function GET(request: Request) {
|
|
410
|
+
const { searchParams } = new URL(request.url);
|
|
411
|
+
const id = searchParams.get('id');
|
|
412
|
+
|
|
413
|
+
if (!validateRequiredParams({ id })) {
|
|
414
|
+
return Response.json(
|
|
415
|
+
{ status: 400, error: "Missing required parameter: id" },
|
|
416
|
+
{ status: 400 }
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### External API Length Limitations
|
|
423
|
+
|
|
424
|
+
When integrating with external APIs, implement validation and truncation directly in the client function:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
export const apiClientFunction = async (
|
|
428
|
+
query: QueryType
|
|
429
|
+
): Promise<ResponseType> => {
|
|
430
|
+
// Truncate fields to respect external API's character limits
|
|
431
|
+
const processedQuery = {
|
|
432
|
+
...query,
|
|
433
|
+
description:
|
|
434
|
+
query.description && query.description.length > 2000
|
|
435
|
+
? query.description.substring(0, 2000)
|
|
436
|
+
: query.description,
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Log truncation events for monitoring
|
|
440
|
+
if (query.description && query.description.length > 2000) {
|
|
441
|
+
log.info(
|
|
442
|
+
{
|
|
443
|
+
originalLength: query.description.length,
|
|
444
|
+
truncatedLength: 2000,
|
|
445
|
+
},
|
|
446
|
+
"Description truncated for external API due to length constraint"
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return await makeApiCall(processedQuery);
|
|
451
|
+
};
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Error Handling
|
|
457
|
+
|
|
458
|
+
### Centralized Error Processing
|
|
459
|
+
|
|
460
|
+
Use Zod error handlers for validation errors:
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
import { ZodError } from "zod";
|
|
464
|
+
import { ErrorSchema } from "@schafevormfenster/rest-commons";
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const validated = MySchema.parse(data);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error instanceof ZodError) {
|
|
470
|
+
return Response.json(
|
|
471
|
+
{
|
|
472
|
+
status: 400,
|
|
473
|
+
timestamp: new Date().toISOString(),
|
|
474
|
+
error: `Validation error: ${error.errors.map(e => e.message).join("; ")}`,
|
|
475
|
+
},
|
|
476
|
+
{ status: 400 }
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### HTTP Status Code Patterns
|
|
483
|
+
|
|
484
|
+
Use semantic HTTP status codes:
|
|
485
|
+
|
|
486
|
+
- **200 OK**: Successful request with data
|
|
487
|
+
- **201 Created**: Resource successfully created
|
|
488
|
+
- **202 Accepted**: Request accepted for processing (async)
|
|
489
|
+
- **400 Bad Request**: Client error (validation, malformed input)
|
|
490
|
+
- **401 Unauthorized**: Authentication required or failed
|
|
491
|
+
- **403 Forbidden**: Authenticated but not authorized
|
|
492
|
+
- **404 Not Found**: Resource not found
|
|
493
|
+
- **409 Conflict**: Resource already exists or conflict
|
|
494
|
+
- **500 Internal Server Error**: Server-side error
|
|
495
|
+
|
|
496
|
+
### Correlation IDs
|
|
497
|
+
|
|
498
|
+
Include correlation IDs in all responses for request tracing:
|
|
499
|
+
|
|
500
|
+
```typescript
|
|
501
|
+
import { getCorrelationId } from "@schafevormfenster/rest-commons";
|
|
502
|
+
|
|
503
|
+
export async function GET(request: Request) {
|
|
504
|
+
const correlationId = getCorrelationId(request);
|
|
505
|
+
|
|
506
|
+
const response = Response.json({ data: [] });
|
|
507
|
+
response.headers.set('X-Correlation-Id', correlationId);
|
|
508
|
+
return response;
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## API Design Patterns
|
|
515
|
+
|
|
516
|
+
### Unified GET/POST Endpoints
|
|
517
|
+
|
|
518
|
+
Support identical request schemas for both GET and POST methods:
|
|
519
|
+
|
|
520
|
+
**GET Request** (simple parameters):
|
|
521
|
+
```bash
|
|
522
|
+
GET /api/{token}/classify?tags=museum,kultur
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
**POST Request** (rich context):
|
|
526
|
+
```bash
|
|
527
|
+
POST /api/{token}/classify
|
|
528
|
+
{
|
|
529
|
+
"tags": ["museum", "kultur"],
|
|
530
|
+
"summary": "Annual city museum concert",
|
|
531
|
+
"description": "Classical music performance"
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**Implementation**:
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
// Flexible schema accepts both string and array formats
|
|
539
|
+
const TagsFieldSchema = z
|
|
540
|
+
.union([
|
|
541
|
+
z.string().transform((str) =>
|
|
542
|
+
str.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
|
|
543
|
+
),
|
|
544
|
+
z.array(z.string()),
|
|
545
|
+
])
|
|
546
|
+
.optional()
|
|
547
|
+
.default([]);
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
**When to Use GET vs POST**:
|
|
551
|
+
- **GET**: Simple queries, caching, bookmarkable URLs
|
|
552
|
+
- **POST**: Rich context, large payloads, sensitive data
|
|
553
|
+
|
|
554
|
+
### Static-First Processing
|
|
555
|
+
|
|
556
|
+
Optimize performance by checking static mappings before calling AI services:
|
|
557
|
+
|
|
558
|
+
1. **Static Matching**: Check against predefined dictionary
|
|
559
|
+
2. **AI Fallback**: Use AI only when no static match exists
|
|
560
|
+
|
|
561
|
+
**Benefits**:
|
|
562
|
+
- Fast response (no external API calls)
|
|
563
|
+
- Cost reduction (fewer AI API calls)
|
|
564
|
+
- Deterministic results (consistent outcomes)
|
|
565
|
+
|
|
566
|
+
**Example**:
|
|
567
|
+
```typescript
|
|
568
|
+
// 1. Try static matching first
|
|
569
|
+
const staticResult = staticMappingService.lookup(query);
|
|
570
|
+
|
|
571
|
+
if (staticResult) {
|
|
572
|
+
log.info({ query }, "Static match found");
|
|
573
|
+
return staticResult;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 2. Fall back to AI service
|
|
577
|
+
log.info({ query }, "No static match, using AI");
|
|
578
|
+
return await aiService.classify(query);
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
## Testing Guidelines
|
|
584
|
+
|
|
585
|
+
### E2E Testing with Playwright
|
|
586
|
+
|
|
587
|
+
E2E tests use Playwright's request context to test HTTP endpoints directly.
|
|
588
|
+
|
|
589
|
+
#### Environment Configuration
|
|
590
|
+
|
|
591
|
+
**Use ONLY `APITEST_*` environment variables**:
|
|
592
|
+
|
|
593
|
+
```typescript
|
|
594
|
+
// ✅ Correct - Use APITEST_* env vars directly
|
|
595
|
+
const baseURL = process.env.APITEST_BASE_URL;
|
|
596
|
+
const readToken = process.env.APITEST_READ_ACCESS_TOKEN;
|
|
597
|
+
|
|
598
|
+
// ❌ Wrong - Don't use production tokens
|
|
599
|
+
const readToken = process.env.READ_ACCESS_TOKENS?.split(",")[0];
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
⚠️ **Enforced by ESLint**: `testing/enforce-apitest-env-vars` prevents incorrect environment variable usage.
|
|
603
|
+
|
|
604
|
+
#### Required Environment Variables
|
|
605
|
+
|
|
606
|
+
Create `.env.local` for development:
|
|
607
|
+
|
|
608
|
+
```bash
|
|
609
|
+
APITEST_BASE_URL=http://localhost:8000
|
|
610
|
+
APITEST_READ_ACCESS_TOKEN=test-read-token
|
|
611
|
+
APITEST_WRITE_ACCESS_TOKEN=test-write-token
|
|
612
|
+
APITEST_ADMIN_ACCESS_TOKEN=test-admin-token
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
#### Test Commands
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
# Local development
|
|
619
|
+
pnpm e2e
|
|
620
|
+
|
|
621
|
+
# Production environment
|
|
622
|
+
pnpm e2e:prod
|
|
623
|
+
|
|
624
|
+
# CI environment
|
|
625
|
+
pnpm e2e:ci
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
#### Test Organization
|
|
629
|
+
|
|
630
|
+
```text
|
|
631
|
+
e2e/
|
|
632
|
+
├── health.spec.ts # Health endpoint tests
|
|
633
|
+
├── auth-access-tokens.spec.ts # Authentication tests
|
|
634
|
+
├── categories.spec.ts # Categories API tests
|
|
635
|
+
└── datasets/ # Large dataset tests (tagged @datasets)
|
|
636
|
+
└── sample-dataset.spec.ts
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
#### Error Message Matching
|
|
640
|
+
|
|
641
|
+
Use **loose regex matching** for error messages:
|
|
642
|
+
|
|
643
|
+
```typescript
|
|
644
|
+
// ✅ CORRECT - Loose comparison with regex
|
|
645
|
+
test("should return validation error", async ({ request }) => {
|
|
646
|
+
const response = await request.post("/api/events", {
|
|
647
|
+
data: { invalid: "data" },
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const body = await response.json();
|
|
651
|
+
expect(body.error).toMatch(/validation\s+error/i);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// ❌ WRONG - Exact string matching (brittle)
|
|
655
|
+
test("should return validation error", async ({ request }) => {
|
|
656
|
+
const response = await request.post("/api/events", {
|
|
657
|
+
data: { invalid: "data" },
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const body = await response.json();
|
|
661
|
+
expect(body.error).toContain("Validation Error"); // Breaks if case changes
|
|
662
|
+
});
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
⚠️ **Enforced by ESLint**: `testing/prefer-loose-error-matching` encourages flexible error matching.
|
|
666
|
+
|
|
667
|
+
#### Test File Naming
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
// ✅ CORRECT - Playwright tests use .spec.ts
|
|
671
|
+
// e2e/health.spec.ts
|
|
672
|
+
|
|
673
|
+
test("should return health status", async ({ request }) => {
|
|
674
|
+
const response = await request.get("/api/health");
|
|
675
|
+
expect(response.status()).toBe(200);
|
|
676
|
+
});
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
⚠️ **Enforced by ESLint**: `testing/enforce-test-file-naming` ensures proper naming conventions.
|
|
680
|
+
|
|
681
|
+
### Unit Testing
|
|
682
|
+
|
|
683
|
+
Unit tests use Vitest and should focus on business logic:
|
|
684
|
+
|
|
685
|
+
```typescript
|
|
686
|
+
// ✅ CORRECT - Vitest tests use .test.ts
|
|
687
|
+
// src/helpers/slugify.test.ts
|
|
688
|
+
|
|
689
|
+
import { describe, it, expect } from "vitest";
|
|
690
|
+
import { slugify } from "./slugify";
|
|
691
|
+
|
|
692
|
+
describe("slugify", () => {
|
|
693
|
+
it("should convert text to slug", () => {
|
|
694
|
+
expect(slugify("Hello World")).toBe("hello-world");
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
## ESLint Rules for REST APIs
|
|
702
|
+
|
|
703
|
+
This section details the most important ESLint rules for REST API development. These rules are automatically enforced when you use `@schafevormfenster/eslint-config`.
|
|
704
|
+
|
|
705
|
+
### Critical Rules
|
|
706
|
+
|
|
707
|
+
These rules are **enabled as errors** and will break your build if violated:
|
|
708
|
+
|
|
709
|
+
#### 1. `rest/enforce-api-route-structure`
|
|
710
|
+
|
|
711
|
+
**Purpose**: Ensures every API route has required files (contract, schema, tests).
|
|
712
|
+
|
|
713
|
+
**What it checks**:
|
|
714
|
+
- Every `route.ts` must have a co-located `route.test.ts`
|
|
715
|
+
- Every route directory must have a `*.contract.ts` file
|
|
716
|
+
- Every route directory must have a `*.schema.ts` file
|
|
717
|
+
|
|
718
|
+
**Example**:
|
|
719
|
+
```text
|
|
720
|
+
✅ Correct structure:
|
|
721
|
+
app/api/[token]/classify/
|
|
722
|
+
├── route.ts
|
|
723
|
+
├── route.test.ts
|
|
724
|
+
├── classify.contract.ts
|
|
725
|
+
└── classify.schema.ts
|
|
726
|
+
|
|
727
|
+
❌ Incorrect structure:
|
|
728
|
+
app/api/classify/
|
|
729
|
+
└── route.ts # Missing required files!
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
**Why it matters**: Enforces contract-first development and ensures comprehensive test coverage.
|
|
733
|
+
|
|
734
|
+
#### 2. `rest/enforce-schema-type-export`
|
|
735
|
+
|
|
736
|
+
**Purpose**: Every Zod schema must have a TypeScript type export.
|
|
737
|
+
|
|
738
|
+
**What it checks**:
|
|
739
|
+
- All exported Zod schemas in `*.schema.ts` files
|
|
740
|
+
- Ensures `z.infer<typeof Schema>` type is exported
|
|
741
|
+
|
|
742
|
+
**Example**:
|
|
743
|
+
```typescript
|
|
744
|
+
// ✅ Correct
|
|
745
|
+
export const UserSchema = z.object({ id: z.string() });
|
|
746
|
+
export type User = z.infer<typeof UserSchema>;
|
|
747
|
+
|
|
748
|
+
// ❌ Incorrect
|
|
749
|
+
export const UserSchema = z.object({ id: z.string() });
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
**Why it matters**: Maintains type safety and ensures consistent type usage across the codebase.
|
|
753
|
+
|
|
754
|
+
#### 3. `rest/enforce-token-in-path`
|
|
755
|
+
|
|
756
|
+
**Purpose**: Enforces token-based authentication in API routes.
|
|
757
|
+
|
|
758
|
+
**What it checks**:
|
|
759
|
+
- Routes include `[token]` in path OR validate `params.token` using `checkAccessToken()`
|
|
760
|
+
- Public routes (health, openapi, etc.) are automatically excluded
|
|
761
|
+
|
|
762
|
+
**Example**:
|
|
763
|
+
```typescript
|
|
764
|
+
// ✅ Correct
|
|
765
|
+
export async function GET({ params }: { params: { token: string } }) {
|
|
766
|
+
checkAccessToken(params.token);
|
|
767
|
+
// ...
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ❌ Incorrect
|
|
771
|
+
export async function GET(request: Request) {
|
|
772
|
+
// No token validation!
|
|
773
|
+
}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
**Why it matters**: Ensures proper authentication on all protected endpoints.
|
|
777
|
+
|
|
778
|
+
#### 4. `caching/no-use-cache-in-post`
|
|
779
|
+
|
|
780
|
+
**Purpose**: Prevents incorrect cache directive usage in POST handlers.
|
|
781
|
+
|
|
782
|
+
**What it checks**:
|
|
783
|
+
- No `"use cache"` directive in POST request handlers
|
|
784
|
+
|
|
785
|
+
**Example**:
|
|
786
|
+
```typescript
|
|
787
|
+
// ✅ Correct
|
|
788
|
+
import { unstable_cache } from 'next/cache';
|
|
789
|
+
export async function POST(request) {
|
|
790
|
+
const cached = await unstable_cache(async () => {...}, ['key'])();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ❌ Incorrect
|
|
794
|
+
"use cache";
|
|
795
|
+
export async function POST(request) { ... }
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
**Why it matters**: `"use cache"` doesn't work with POST requests; prevents subtle bugs.
|
|
799
|
+
|
|
800
|
+
### Important Rules
|
|
801
|
+
|
|
802
|
+
These rules are **enabled as warnings** and should be addressed:
|
|
803
|
+
|
|
804
|
+
#### 5. `rest/prefer-schema-extension`
|
|
805
|
+
|
|
806
|
+
**Purpose**: Encourages reusing standard response schemas instead of defining from scratch.
|
|
807
|
+
|
|
808
|
+
**What it checks**:
|
|
809
|
+
- Warns when defining schemas with common keys (status, timestamp, data) from scratch
|
|
810
|
+
- Suggests using `.extend()` or `.merge()` with standard schemas
|
|
811
|
+
|
|
812
|
+
**Example**:
|
|
813
|
+
```typescript
|
|
814
|
+
// ✅ Preferred
|
|
815
|
+
import { ResultSchema } from "@schafevormfenster/rest-commons";
|
|
816
|
+
export const MyResponseSchema = ResultSchema.extend({
|
|
817
|
+
data: MyDataSchema,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// ⚠️ Warning
|
|
821
|
+
export const MyResponseSchema = z.object({
|
|
822
|
+
status: z.number(),
|
|
823
|
+
timestamp: z.string(),
|
|
824
|
+
data: MyDataSchema,
|
|
825
|
+
});
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
**Why it matters**: Maintains consistent response structures across the API.
|
|
829
|
+
|
|
830
|
+
#### 6. `caching/enforce-semantic-cache-headers`
|
|
831
|
+
|
|
832
|
+
**Purpose**: Ensures proper cache control headers in GET requests.
|
|
833
|
+
|
|
834
|
+
**What it checks**:
|
|
835
|
+
- All GET route handlers set `Cache-Control` header
|
|
836
|
+
|
|
837
|
+
**Example**:
|
|
838
|
+
```typescript
|
|
839
|
+
// ✅ Correct
|
|
840
|
+
export async function GET(request) {
|
|
841
|
+
const response = Response.json({ data: [] });
|
|
842
|
+
response.headers.set('Cache-Control', 'public, max-age=3600');
|
|
843
|
+
return response;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ⚠️ Warning
|
|
847
|
+
export async function GET(request) {
|
|
848
|
+
return Response.json({ data: [] }); // Missing Cache-Control
|
|
849
|
+
}
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
**Why it matters**: Proper caching improves performance and prevents unintended caching issues.
|
|
853
|
+
|
|
854
|
+
### Best Practice Rules
|
|
855
|
+
|
|
856
|
+
These rules help maintain code quality:
|
|
857
|
+
|
|
858
|
+
#### 7. `testing/enforce-apitest-env-vars`
|
|
859
|
+
|
|
860
|
+
**Purpose**: Ensures E2E tests use isolated test environment variables.
|
|
861
|
+
|
|
862
|
+
**What it checks**:
|
|
863
|
+
- E2E tests only access `APITEST_*` environment variables
|
|
864
|
+
- Prevents accidental use of production tokens
|
|
865
|
+
|
|
866
|
+
**Example**:
|
|
867
|
+
```typescript
|
|
868
|
+
// ✅ Correct
|
|
869
|
+
const baseURL = process.env.APITEST_BASE_URL;
|
|
870
|
+
|
|
871
|
+
// ❌ Incorrect
|
|
872
|
+
const baseURL = process.env.BASE_URL || "http://localhost:8000";
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
**Why it matters**: Prevents accidentally running tests against production.
|
|
876
|
+
|
|
877
|
+
#### 8. `testing/prefer-loose-error-matching`
|
|
878
|
+
|
|
879
|
+
**Purpose**: Encourages flexible error message matching in tests.
|
|
880
|
+
|
|
881
|
+
**What it checks**:
|
|
882
|
+
- Warns about exact string matching for error messages
|
|
883
|
+
- Suggests regex-based matching
|
|
884
|
+
|
|
885
|
+
**Example**:
|
|
886
|
+
```typescript
|
|
887
|
+
// ✅ Correct
|
|
888
|
+
expect(body.error).toMatch(/validation\s+error/i);
|
|
889
|
+
|
|
890
|
+
// ⚠️ Warning
|
|
891
|
+
expect(body.error).toContain("Validation Error");
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
**Why it matters**: Tests remain stable when error messages are improved.
|
|
895
|
+
|
|
896
|
+
#### 9. `testing/enforce-test-file-naming`
|
|
897
|
+
|
|
898
|
+
**Purpose**: Enforces correct test file naming conventions.
|
|
899
|
+
|
|
900
|
+
**What it checks**:
|
|
901
|
+
- Playwright tests use `*.spec.ts`
|
|
902
|
+
- Vitest tests use `*.test.ts`
|
|
903
|
+
|
|
904
|
+
**Example**:
|
|
905
|
+
```text
|
|
906
|
+
✅ Correct:
|
|
907
|
+
e2e/health.spec.ts # Playwright
|
|
908
|
+
src/helpers/slugify.test.ts # Vitest
|
|
909
|
+
|
|
910
|
+
❌ Incorrect:
|
|
911
|
+
e2e/health.test.ts # Should be .spec.ts
|
|
912
|
+
src/helpers/slugify.spec.ts # Should be .test.ts
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
**Why it matters**: Maintains consistent naming conventions and helps test runners find tests.
|
|
916
|
+
|
|
917
|
+
#### 10. `stack/no-forbidden-imports`
|
|
918
|
+
|
|
919
|
+
**Purpose**: Prevents usage of forbidden libraries with preferred alternatives.
|
|
920
|
+
|
|
921
|
+
**What it checks**:
|
|
922
|
+
- Forbids `axios` (use Fetch API)
|
|
923
|
+
- Forbids `moment` (use date-fns)
|
|
924
|
+
- Forbids `jest` (use Vitest)
|
|
925
|
+
|
|
926
|
+
**Example**:
|
|
927
|
+
```typescript
|
|
928
|
+
// ✅ Correct
|
|
929
|
+
const response = await fetch(url);
|
|
930
|
+
|
|
931
|
+
// ❌ Incorrect
|
|
932
|
+
import axios from 'axios';
|
|
933
|
+
const response = await axios.get(url);
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
**Why it matters**: Enforces consistent tech stack choices across the monorepo.
|
|
937
|
+
|
|
938
|
+
### Enabling ESLint Rules
|
|
939
|
+
|
|
940
|
+
In your `eslint.config.mjs`:
|
|
941
|
+
|
|
942
|
+
```javascript
|
|
943
|
+
import svfNext from "@schafevormfenster/eslint-config/next";
|
|
944
|
+
import svfRest from "@schafevormfenster/eslint-config/rest";
|
|
945
|
+
import svfCaching from "@schafevormfenster/eslint-config/caching";
|
|
946
|
+
import svfPlaywright from "@schafevormfenster/eslint-config/playwright";
|
|
947
|
+
|
|
948
|
+
export default [
|
|
949
|
+
...svfNext, // Base Next.js rules
|
|
950
|
+
...svfRest, // REST API rules (includes critical rules)
|
|
951
|
+
...svfCaching, // Caching rules
|
|
952
|
+
...svfPlaywright, // E2E testing rules (only for e2e/ directory)
|
|
953
|
+
];
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
---
|
|
957
|
+
|
|
958
|
+
## Development Workflow
|
|
959
|
+
|
|
960
|
+
### 1. Create API Endpoint Structure
|
|
961
|
+
|
|
962
|
+
```bash
|
|
963
|
+
# Create route directory
|
|
964
|
+
mkdir -p app/api/[token]/my-endpoint
|
|
965
|
+
|
|
966
|
+
# Create required files
|
|
967
|
+
touch app/api/[token]/my-endpoint/route.ts
|
|
968
|
+
touch app/api/[token]/my-endpoint/route.test.ts
|
|
969
|
+
touch app/api/[token]/my-endpoint/my-endpoint.contract.ts
|
|
970
|
+
touch app/api/[token]/my-endpoint/my-endpoint.schema.ts
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
### 2. Define Zod Schemas
|
|
974
|
+
|
|
975
|
+
```typescript
|
|
976
|
+
// my-endpoint.schema.ts
|
|
977
|
+
import { z } from "zod";
|
|
978
|
+
import { ResultSchema } from "@schafevormfenster/rest-commons";
|
|
979
|
+
|
|
980
|
+
export const MyRequestSchema = z.object({
|
|
981
|
+
id: z.string(),
|
|
982
|
+
name: z.string(),
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
export type MyRequest = z.infer<typeof MyRequestSchema>;
|
|
986
|
+
|
|
987
|
+
export const MyDataSchema = z.object({
|
|
988
|
+
id: z.string(),
|
|
989
|
+
name: z.string(),
|
|
990
|
+
createdAt: z.string(),
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
export type MyData = z.infer<typeof MyDataSchema>;
|
|
994
|
+
|
|
995
|
+
export const MyResponseSchema = ResultSchema.extend({
|
|
996
|
+
data: MyDataSchema,
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
export type MyResponse = z.infer<typeof MyResponseSchema>;
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
### 3. Define API Contract
|
|
1003
|
+
|
|
1004
|
+
```typescript
|
|
1005
|
+
// my-endpoint.contract.ts
|
|
1006
|
+
import { initContract } from "@ts-rest/core";
|
|
1007
|
+
import { MyRequestSchema, MyResponseSchema } from "./my-endpoint.schema";
|
|
1008
|
+
|
|
1009
|
+
const c = initContract();
|
|
1010
|
+
|
|
1011
|
+
export const myEndpointContract = c.router({
|
|
1012
|
+
get: {
|
|
1013
|
+
method: "GET",
|
|
1014
|
+
path: "/api/:token/my-endpoint/:id",
|
|
1015
|
+
responses: {
|
|
1016
|
+
200: MyResponseSchema,
|
|
1017
|
+
400: ErrorSchema,
|
|
1018
|
+
401: ErrorSchema,
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
post: {
|
|
1022
|
+
method: "POST",
|
|
1023
|
+
path: "/api/:token/my-endpoint",
|
|
1024
|
+
body: MyRequestSchema,
|
|
1025
|
+
responses: {
|
|
1026
|
+
201: MyResponseSchema,
|
|
1027
|
+
400: ErrorSchema,
|
|
1028
|
+
401: ErrorSchema,
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
### 4. Implement Route Handler
|
|
1035
|
+
|
|
1036
|
+
```typescript
|
|
1037
|
+
// route.ts
|
|
1038
|
+
import { checkAccessToken } from "@/security/auth";
|
|
1039
|
+
import { getCorrelationId } from "@schafevormfenster/rest-commons";
|
|
1040
|
+
import { MyRequestSchema } from "./my-endpoint.schema";
|
|
1041
|
+
|
|
1042
|
+
export async function GET({
|
|
1043
|
+
params,
|
|
1044
|
+
}: {
|
|
1045
|
+
params: { token: string; id: string };
|
|
1046
|
+
}) {
|
|
1047
|
+
checkAccessToken(params.token);
|
|
1048
|
+
|
|
1049
|
+
const correlationId = getCorrelationId();
|
|
1050
|
+
|
|
1051
|
+
// Business logic here
|
|
1052
|
+
const data = await fetchData(params.id);
|
|
1053
|
+
|
|
1054
|
+
const response = Response.json({
|
|
1055
|
+
status: 200,
|
|
1056
|
+
timestamp: new Date().toISOString(),
|
|
1057
|
+
data,
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
response.headers.set('X-Correlation-Id', correlationId);
|
|
1061
|
+
response.headers.set('Cache-Control', 'public, max-age=3600');
|
|
1062
|
+
|
|
1063
|
+
return response;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export async function POST({
|
|
1067
|
+
params,
|
|
1068
|
+
request,
|
|
1069
|
+
}: {
|
|
1070
|
+
params: { token: string };
|
|
1071
|
+
request: Request;
|
|
1072
|
+
}) {
|
|
1073
|
+
checkAccessToken(params.token);
|
|
1074
|
+
|
|
1075
|
+
const body = await request.json();
|
|
1076
|
+
const validated = MyRequestSchema.parse(body);
|
|
1077
|
+
|
|
1078
|
+
// Business logic here
|
|
1079
|
+
const data = await createData(validated);
|
|
1080
|
+
|
|
1081
|
+
return Response.json(
|
|
1082
|
+
{
|
|
1083
|
+
status: 201,
|
|
1084
|
+
timestamp: new Date().toISOString(),
|
|
1085
|
+
data,
|
|
1086
|
+
},
|
|
1087
|
+
{ status: 201 }
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
### 5. Write Tests
|
|
1093
|
+
|
|
1094
|
+
```typescript
|
|
1095
|
+
// route.test.ts
|
|
1096
|
+
import { test, expect } from "@playwright/test";
|
|
1097
|
+
|
|
1098
|
+
const READ_TOKEN = process.env.APITEST_READ_ACCESS_TOKEN;
|
|
1099
|
+
const WRITE_TOKEN = process.env.APITEST_WRITE_ACCESS_TOKEN;
|
|
1100
|
+
|
|
1101
|
+
test.describe("My Endpoint API", () => {
|
|
1102
|
+
test("GET requires authentication", async ({ request }) => {
|
|
1103
|
+
const response = await request.get("/api/invalid-token/my-endpoint/123");
|
|
1104
|
+
expect(response.status()).toBe(401);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
test("GET returns data with valid token", async ({ request }) => {
|
|
1108
|
+
const response = await request.get(
|
|
1109
|
+
`/api/${READ_TOKEN}/my-endpoint/123`
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
expect(response.status()).toBe(200);
|
|
1113
|
+
|
|
1114
|
+
const body = await response.json();
|
|
1115
|
+
expect(body).toHaveProperty("status", 200);
|
|
1116
|
+
expect(body).toHaveProperty("timestamp");
|
|
1117
|
+
expect(body).toHaveProperty("data");
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
test("POST creates resource", async ({ request }) => {
|
|
1121
|
+
const response = await request.post(`/api/${WRITE_TOKEN}/my-endpoint`, {
|
|
1122
|
+
data: {
|
|
1123
|
+
id: "123",
|
|
1124
|
+
name: "Test",
|
|
1125
|
+
},
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
expect(response.status()).toBe(201);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("POST validates input", async ({ request }) => {
|
|
1132
|
+
const response = await request.post(`/api/${WRITE_TOKEN}/my-endpoint`, {
|
|
1133
|
+
data: { invalid: "data" },
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
expect(response.status()).toBe(400);
|
|
1137
|
+
|
|
1138
|
+
const body = await response.json();
|
|
1139
|
+
expect(body.error).toMatch(/validation/i);
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
### 6. Run Linter and Tests
|
|
1145
|
+
|
|
1146
|
+
```bash
|
|
1147
|
+
# Run ESLint
|
|
1148
|
+
pnpm lint
|
|
1149
|
+
|
|
1150
|
+
# Run unit tests
|
|
1151
|
+
pnpm test
|
|
1152
|
+
|
|
1153
|
+
# Run E2E tests
|
|
1154
|
+
pnpm e2e
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
### 7. Build and Deploy
|
|
1158
|
+
|
|
1159
|
+
```bash
|
|
1160
|
+
# Build the package
|
|
1161
|
+
pnpm build
|
|
1162
|
+
|
|
1163
|
+
# The package is ready to publish
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
---
|
|
1167
|
+
|
|
1168
|
+
## Additional Resources
|
|
1169
|
+
|
|
1170
|
+
- **REST Service Design Guide**: See `/packages/eslint-plugin/docs/rest-service-design-guide.md`
|
|
1171
|
+
- **E2E Testing Guide**: See `/packages/eslint-plugin/docs/instructions/rest-api-e2e-testing-guide.md`
|
|
1172
|
+
- **Tech Stack**: See `/packages/eslint-plugin/docs/instructions/tech-stack.md`
|
|
1173
|
+
- **ESLint Plugin**: See `/packages/eslint-plugin/README.md`
|
|
1174
|
+
- **ESLint Config**: See `/packages/eslint-config/README.md`
|
|
1175
|
+
|
|
1176
|
+
---
|
|
1177
|
+
|
|
1178
|
+
## Getting Help
|
|
1179
|
+
|
|
1180
|
+
If you have questions or need help:
|
|
1181
|
+
|
|
1182
|
+
1. Check the documentation in `/packages/eslint-plugin/docs/`
|
|
1183
|
+
2. Review existing implementations in the monorepo
|
|
1184
|
+
3. Ask in the team chat or create an issue
|
|
1185
|
+
|
|
1186
|
+
---
|
|
1187
|
+
|
|
1188
|
+
## License
|
|
1189
|
+
|
|
1190
|
+
See the root LICENSE file for license information.
|