@morphql/server 0.1.3
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/.dockerignore +15 -0
- package/.prettierrc +4 -0
- package/Dockerfile +63 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/docker-compose.yml +32 -0
- package/eslint.config.mjs +35 -0
- package/nest-cli.json +8 -0
- package/package.json +59 -0
- package/src/app.module.ts +9 -0
- package/src/auth.guard.ts +56 -0
- package/src/main.ts +19 -0
- package/src/morph.controller.ts +153 -0
- package/test/app.e2e-spec.ts +83 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +16 -0
package/.dockerignore
ADDED
package/.prettierrc
ADDED
package/Dockerfile
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Stage 1: Builder
|
|
2
|
+
FROM node:24-alpine AS builder
|
|
3
|
+
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
|
|
6
|
+
# Copy root package files
|
|
7
|
+
COPY package*.json ./
|
|
8
|
+
|
|
9
|
+
# Copy workspace package files (maintaining structure)
|
|
10
|
+
COPY packages/core/package*.json ./packages/core/
|
|
11
|
+
COPY packages/server/package*.json ./packages/server/
|
|
12
|
+
|
|
13
|
+
# Install ALL dependencies (including devDeps for building)
|
|
14
|
+
RUN npm ci
|
|
15
|
+
|
|
16
|
+
# Copy TypeScript configs
|
|
17
|
+
COPY tsconfig*.json ./
|
|
18
|
+
COPY packages/core/tsconfig*.json ./packages/core/
|
|
19
|
+
COPY packages/server/tsconfig*.json ./packages/server/
|
|
20
|
+
COPY packages/server/nest-cli.json ./packages/server/
|
|
21
|
+
|
|
22
|
+
# Copy source code for both packages
|
|
23
|
+
COPY packages/core/src ./packages/core/src
|
|
24
|
+
COPY packages/server/src ./packages/server/src
|
|
25
|
+
COPY packages/server/test ./packages/server/test
|
|
26
|
+
|
|
27
|
+
# Build core first (server depends on it)
|
|
28
|
+
RUN npm run build --workspace=@morphql/core
|
|
29
|
+
|
|
30
|
+
# Build server
|
|
31
|
+
RUN npm run build --workspace=server
|
|
32
|
+
|
|
33
|
+
# Prune dev dependencies
|
|
34
|
+
RUN npm prune --omit=dev
|
|
35
|
+
|
|
36
|
+
# Stage 2: Production
|
|
37
|
+
FROM node:24-alpine AS production
|
|
38
|
+
|
|
39
|
+
WORKDIR /app
|
|
40
|
+
|
|
41
|
+
# Copy root metadata
|
|
42
|
+
COPY package*.json ./
|
|
43
|
+
|
|
44
|
+
# Copy the entire built workspace from builder
|
|
45
|
+
# This includes the pruned node_modules which already has the correct links structure
|
|
46
|
+
COPY --from=builder /app/node_modules ./node_modules
|
|
47
|
+
COPY --from=builder /app/packages ./packages
|
|
48
|
+
|
|
49
|
+
# Create non-root user
|
|
50
|
+
RUN addgroup -g 1001 -S nodejs && \
|
|
51
|
+
adduser -S nestjs -u 1001
|
|
52
|
+
|
|
53
|
+
USER nestjs
|
|
54
|
+
|
|
55
|
+
ENV NODE_ENV=production
|
|
56
|
+
EXPOSE 3000
|
|
57
|
+
|
|
58
|
+
# Set WORKDIR to server for correct execution context
|
|
59
|
+
WORKDIR /app/packages/server
|
|
60
|
+
|
|
61
|
+
# Execute using local package script
|
|
62
|
+
CMD ["npm", "run", "start:prod"]
|
|
63
|
+
#CMD ["tail", "-f", "/dev/null"]
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniele Traverso
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# MorphQL Server
|
|
2
|
+
|
|
3
|
+
A high-performance, stateless NestJS API for the MorphQL transformation engine.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This server provides a RESTful interface to compile and execute Morph Query Language (MorphQL) transformations. Built with NestJS, it's designed to be a lightweight, scalable microservice that can be deployed in containerized environments.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- 🚀 **Stateless Execution**: Designed for horizontal scaling
|
|
12
|
+
- 🔄 **Isomorphic Engine**: Run the exact same transformations as the client-side library
|
|
13
|
+
- ⚡ **Redis Caching**: Built-in compiled query caching for high-throughput scenarios
|
|
14
|
+
- 🐳 **Docker Ready**: Production-optimized multi-stage container images
|
|
15
|
+
- 🔐 **API Key Authentication**: Optional security via `X-API-KEY` header
|
|
16
|
+
- 📊 **Swagger Documentation**: Interactive API docs at `/api`
|
|
17
|
+
- 🏥 **Health Checks**: Liveness and readiness endpoints for orchestration
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### Docker Compose (Recommended)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Start server + Redis
|
|
25
|
+
docker compose up -d
|
|
26
|
+
|
|
27
|
+
# View logs
|
|
28
|
+
docker compose logs -f server
|
|
29
|
+
|
|
30
|
+
# Stop services
|
|
31
|
+
docker compose down
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The server will be available at `http://localhost:3000` with Swagger docs at `http://localhost:3000/api`.
|
|
35
|
+
|
|
36
|
+
### Development Mode
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# From monorepo root
|
|
40
|
+
npm run server
|
|
41
|
+
|
|
42
|
+
# Or from packages/server
|
|
43
|
+
npm run start:dev
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API Reference
|
|
47
|
+
|
|
48
|
+
All endpoints are prefixed with `/v1`. Full interactive documentation is available at `/api` when the server is running.
|
|
49
|
+
|
|
50
|
+
### 1. Execute Transformation
|
|
51
|
+
|
|
52
|
+
Compile and execute a query against data in a single request.
|
|
53
|
+
|
|
54
|
+
**Endpoint**: `POST /v1/execute`
|
|
55
|
+
|
|
56
|
+
**Request**:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"query": "from json to json transform set firstName = split(fullName, ' ')[0]",
|
|
61
|
+
"data": { "fullName": "John Doe" }
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Response**:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"success": true,
|
|
70
|
+
"result": { "firstName": "John" },
|
|
71
|
+
"executionTime": 2.5
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Example with curl**:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
curl -X POST http://localhost:3000/v1/execute \
|
|
79
|
+
-H "Content-Type: application/json" \
|
|
80
|
+
-d '{
|
|
81
|
+
"query": "from json to json transform set name = fullName",
|
|
82
|
+
"data": { "fullName": "Jane Smith" }
|
|
83
|
+
}'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 2. Compile Query
|
|
87
|
+
|
|
88
|
+
Get the generated JavaScript code for a query without executing it.
|
|
89
|
+
|
|
90
|
+
**Endpoint**: `POST /v1/compile`
|
|
91
|
+
|
|
92
|
+
**Request**:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"query": "from json to xml transform set name = fullName"
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Response**:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"success": true,
|
|
105
|
+
"code": "function(source) { /* generated code */ }"
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 3. Health Checks
|
|
110
|
+
|
|
111
|
+
**Liveness**: `GET /v1/health`
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "status": "ok", "timestamp": "2026-01-20T00:00:00.000Z" }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Readiness**: `GET /v1/health/ready`
|
|
118
|
+
|
|
119
|
+
- Returns `200` if service and Redis (if configured) are ready
|
|
120
|
+
- Returns `503` if Redis is configured but unavailable
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
Configure the server via environment variables:
|
|
125
|
+
|
|
126
|
+
| Variable | Description | Default | Required |
|
|
127
|
+
| -------------- | ------------------------------------ | ------- | -------- |
|
|
128
|
+
| `PORT` | Server port | `3000` | No |
|
|
129
|
+
| `NODE_ENV` | Environment mode | - | No |
|
|
130
|
+
| `REDIS_HOST` | Redis hostname for caching | - | No |
|
|
131
|
+
| `REDIS_PORT` | Redis port | `6379` | No |
|
|
132
|
+
| `REDIS_PREFIX` | Cache key prefix | `morphql:` | No |
|
|
133
|
+
| `API_KEY` | Optional API key for auth | - | No |
|
|
134
|
+
| `API_KEY_FILE` | Optional API key file (for secrets) | - | No |
|
|
135
|
+
|
|
136
|
+
**Note**: If `REDIS_HOST` is not set, the server runs without caching (queries are compiled on every request).
|
|
137
|
+
|
|
138
|
+
## Authentication
|
|
139
|
+
|
|
140
|
+
The server supports optional API key authentication via the `X-API-KEY` header.
|
|
141
|
+
|
|
142
|
+
**Enable authentication**:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# Set API_KEY environment variable
|
|
146
|
+
export API_KEY=your-secret-key
|
|
147
|
+
|
|
148
|
+
# Or in docker-compose.yml
|
|
149
|
+
environment:
|
|
150
|
+
- API_KEY=your-secret-key
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Making authenticated requests**:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
curl -X POST http://localhost:3000/v1/execute \
|
|
157
|
+
-H "X-API-KEY: your-secret-key" \
|
|
158
|
+
-H "Content-Type: application/json" \
|
|
159
|
+
-d '{"query": "...", "data": {...}}'
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
If `API_KEY` is not set, all requests are allowed (useful for development).
|
|
163
|
+
|
|
164
|
+
## Docker Deployment
|
|
165
|
+
|
|
166
|
+
### Building the Image
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# From monorepo root
|
|
170
|
+
docker build -f packages/server/Dockerfile -t morphql-server .
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Running with Docker
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
# Without Redis
|
|
177
|
+
docker run -p 3000:3000 morphql-server
|
|
178
|
+
|
|
179
|
+
# With Redis
|
|
180
|
+
docker run -p 3000:3000 \
|
|
181
|
+
-e REDIS_HOST=redis.example.com \
|
|
182
|
+
-e REDIS_PORT=6379 \
|
|
183
|
+
morphql-server
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Docker Compose Production
|
|
187
|
+
|
|
188
|
+
The included `docker-compose.yml` provides a production-ready setup with:
|
|
189
|
+
|
|
190
|
+
- NestJS server with health checks
|
|
191
|
+
- Redis for query caching
|
|
192
|
+
- Persistent Redis data volume
|
|
193
|
+
- Automatic restart policies
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
### Available Scripts
|
|
198
|
+
|
|
199
|
+
| Command | Description |
|
|
200
|
+
| --------------------- | ------------------------ |
|
|
201
|
+
| `npm run start` | Start in production mode |
|
|
202
|
+
| `npm run start:dev` | Start with hot-reload |
|
|
203
|
+
| `npm run start:debug` | Start with debugger |
|
|
204
|
+
| `npm run build` | Build for production |
|
|
205
|
+
| `npm run test` | Run unit tests |
|
|
206
|
+
| `npm run test:e2e` | Run end-to-end tests |
|
|
207
|
+
| `npm run lint` | Lint and fix code |
|
|
208
|
+
|
|
209
|
+
### Project Structure
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
packages/server/
|
|
213
|
+
├── src/
|
|
214
|
+
│ ├── main.ts # Application entry point
|
|
215
|
+
│ ├── app.module.ts # Root module
|
|
216
|
+
│ ├── morph.controller.ts # API endpoints
|
|
217
|
+
│ └── auth.guard.ts # API key authentication
|
|
218
|
+
├── test/ # E2E tests
|
|
219
|
+
├── Dockerfile # Multi-stage production build
|
|
220
|
+
├── docker-compose.yml # Local deployment stack
|
|
221
|
+
└── package.json
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Performance
|
|
225
|
+
|
|
226
|
+
- **Caching**: When Redis is enabled, compiled queries are cached indefinitely (queries are deterministic)
|
|
227
|
+
- **Stateless**: Each request is independent, enabling horizontal scaling
|
|
228
|
+
- **Async**: All endpoints use async/await for non-blocking I/O
|
|
229
|
+
|
|
230
|
+
## Monitoring
|
|
231
|
+
|
|
232
|
+
The server provides structured logging via NestJS:
|
|
233
|
+
|
|
234
|
+
- Request routing and mapping on startup
|
|
235
|
+
- Error logging with stack traces
|
|
236
|
+
- Performance metrics in `executionTime` field
|
|
237
|
+
|
|
238
|
+
For production monitoring, consider:
|
|
239
|
+
|
|
240
|
+
- Health check endpoints for Kubernetes/Docker Swarm
|
|
241
|
+
- Redis monitoring for cache hit rates
|
|
242
|
+
- Application Performance Monitoring (APM) tools
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
services:
|
|
2
|
+
server:
|
|
3
|
+
build:
|
|
4
|
+
context: ../..
|
|
5
|
+
dockerfile: packages/server/Dockerfile
|
|
6
|
+
ports:
|
|
7
|
+
- "3000:3000"
|
|
8
|
+
environment:
|
|
9
|
+
- NODE_ENV=production
|
|
10
|
+
- REDIS_HOST=redis
|
|
11
|
+
- REDIS_PORT=6379
|
|
12
|
+
depends_on:
|
|
13
|
+
redis:
|
|
14
|
+
condition: service_healthy
|
|
15
|
+
restart: unless-stopped
|
|
16
|
+
|
|
17
|
+
redis:
|
|
18
|
+
image: redis:7-alpine
|
|
19
|
+
ports:
|
|
20
|
+
- "6379:6379"
|
|
21
|
+
volumes:
|
|
22
|
+
- redis-data:/data
|
|
23
|
+
command: redis-server --appendonly yes
|
|
24
|
+
healthcheck:
|
|
25
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
26
|
+
interval: 5s
|
|
27
|
+
timeout: 3s
|
|
28
|
+
retries: 5
|
|
29
|
+
restart: unless-stopped
|
|
30
|
+
|
|
31
|
+
volumes:
|
|
32
|
+
redis-data:
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import eslint from '@eslint/js';
|
|
3
|
+
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
|
4
|
+
import globals from 'globals';
|
|
5
|
+
import tseslint from 'typescript-eslint';
|
|
6
|
+
|
|
7
|
+
export default tseslint.config(
|
|
8
|
+
{
|
|
9
|
+
ignores: ['eslint.config.mjs'],
|
|
10
|
+
},
|
|
11
|
+
eslint.configs.recommended,
|
|
12
|
+
...tseslint.configs.recommendedTypeChecked,
|
|
13
|
+
eslintPluginPrettierRecommended,
|
|
14
|
+
{
|
|
15
|
+
languageOptions: {
|
|
16
|
+
globals: {
|
|
17
|
+
...globals.node,
|
|
18
|
+
...globals.jest,
|
|
19
|
+
},
|
|
20
|
+
sourceType: 'commonjs',
|
|
21
|
+
parserOptions: {
|
|
22
|
+
projectService: true,
|
|
23
|
+
tsconfigRootDir: import.meta.dirname,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
rules: {
|
|
29
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
30
|
+
'@typescript-eslint/no-floating-promises': 'warn',
|
|
31
|
+
'@typescript-eslint/no-unsafe-argument': 'warn',
|
|
32
|
+
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
);
|
package/nest-cli.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@morphql/server",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Stateless Transformation Engine API for MorphQL",
|
|
5
|
+
"author": "Hyperwindmill",
|
|
6
|
+
"private": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "nest build",
|
|
13
|
+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
14
|
+
"start": "nest start",
|
|
15
|
+
"start:dev": "nest start --watch",
|
|
16
|
+
"start:debug": "nest start --debug --watch",
|
|
17
|
+
"start:prod": "node dist/main.js",
|
|
18
|
+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"test:cov": "vitest run --coverage",
|
|
22
|
+
"test:e2e": "vitest run --config ./vitest.config.ts"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@nestjs/common": "^11.0.1",
|
|
26
|
+
"@nestjs/core": "^11.0.1",
|
|
27
|
+
"@nestjs/platform-express": "^11.0.1",
|
|
28
|
+
"@nestjs/swagger": "^11.2.5",
|
|
29
|
+
"@morphql/core": "^0.1.3",
|
|
30
|
+
"ioredis": "^5.9.2",
|
|
31
|
+
"reflect-metadata": "^0.2.2",
|
|
32
|
+
"rxjs": "^7.8.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@eslint/eslintrc": "^3.2.0",
|
|
36
|
+
"@eslint/js": "^9.18.0",
|
|
37
|
+
"@nestjs/cli": "^11.0.0",
|
|
38
|
+
"@nestjs/schematics": "^11.0.0",
|
|
39
|
+
"@nestjs/testing": "^11.0.1",
|
|
40
|
+
"@swc/core": "^1.10.9",
|
|
41
|
+
"@types/express": "^5.0.0",
|
|
42
|
+
"@types/node": "^22.10.7",
|
|
43
|
+
"@types/supertest": "^6.0.2",
|
|
44
|
+
"eslint": "^9.18.0",
|
|
45
|
+
"eslint-config-prettier": "^10.0.1",
|
|
46
|
+
"eslint-plugin-prettier": "^5.2.2",
|
|
47
|
+
"globals": "^16.0.0",
|
|
48
|
+
"prettier": "^3.4.2",
|
|
49
|
+
"source-map-support": "^0.5.21",
|
|
50
|
+
"supertest": "^7.0.0",
|
|
51
|
+
"ts-loader": "^9.5.2",
|
|
52
|
+
"ts-node": "^10.9.2",
|
|
53
|
+
"tsconfig-paths": "^4.2.0",
|
|
54
|
+
"typescript": "^5.7.3",
|
|
55
|
+
"typescript-eslint": "^8.20.0",
|
|
56
|
+
"unplugin-swc": "^1.5.1",
|
|
57
|
+
"vitest": "^3.0.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CanActivate,
|
|
3
|
+
ExecutionContext,
|
|
4
|
+
Injectable,
|
|
5
|
+
UnauthorizedException,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { Request } from 'express';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
|
|
10
|
+
@Injectable()
|
|
11
|
+
export class ApiKeyGuard implements CanActivate {
|
|
12
|
+
private apiKey: string | null = null;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
this.loadApiKey();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private loadApiKey() {
|
|
19
|
+
// 1. Try env var
|
|
20
|
+
if (process.env.API_KEY) {
|
|
21
|
+
this.apiKey = process.env.API_KEY;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Try file (Docker Swarm Secret)
|
|
26
|
+
if (process.env.API_KEY_FILE) {
|
|
27
|
+
try {
|
|
28
|
+
if (fs.existsSync(process.env.API_KEY_FILE)) {
|
|
29
|
+
this.apiKey = fs
|
|
30
|
+
.readFileSync(process.env.API_KEY_FILE, 'utf8')
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error(
|
|
35
|
+
`Failed to load API key from file ${process.env.API_KEY_FILE}`,
|
|
36
|
+
e,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
canActivate(context: ExecutionContext): boolean {
|
|
43
|
+
if (!this.apiKey) {
|
|
44
|
+
return true; // No API Key configured, allow all
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const request = context.switchToHttp().getRequest<Request>();
|
|
48
|
+
const requestKey = request.headers['x-api-key'];
|
|
49
|
+
|
|
50
|
+
if (requestKey === this.apiKey) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw new UnauthorizedException('Invalid or missing API Key');
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NestFactory } from '@nestjs/core';
|
|
2
|
+
import { AppModule } from './app.module.js';
|
|
3
|
+
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
4
|
+
|
|
5
|
+
async function bootstrap() {
|
|
6
|
+
const app = await NestFactory.create(AppModule);
|
|
7
|
+
|
|
8
|
+
const config = new DocumentBuilder()
|
|
9
|
+
.setTitle('MorphQL API')
|
|
10
|
+
.setDescription('Stateless Transformation Engine')
|
|
11
|
+
.setVersion('1.0')
|
|
12
|
+
.addApiKey({ type: 'apiKey', name: 'X-API-KEY', in: 'header' }, 'X-API-KEY')
|
|
13
|
+
.build();
|
|
14
|
+
const document = SwaggerModule.createDocument(app, config);
|
|
15
|
+
SwaggerModule.setup('api', app, document);
|
|
16
|
+
|
|
17
|
+
await app.listen(process.env.PORT ?? 3000);
|
|
18
|
+
}
|
|
19
|
+
bootstrap();
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
3
|
+
import {
|
|
4
|
+
Controller,
|
|
5
|
+
Post,
|
|
6
|
+
Get,
|
|
7
|
+
Body,
|
|
8
|
+
UseGuards,
|
|
9
|
+
BadRequestException,
|
|
10
|
+
InternalServerErrorException,
|
|
11
|
+
ServiceUnavailableException,
|
|
12
|
+
} from '@nestjs/common';
|
|
13
|
+
import { ApiKeyGuard } from './auth.guard.js';
|
|
14
|
+
import { compile } from '@morphql/core';
|
|
15
|
+
import { RedisCache } from '@morphql/core/cache-services';
|
|
16
|
+
import {
|
|
17
|
+
ApiTags,
|
|
18
|
+
ApiOperation,
|
|
19
|
+
ApiProperty,
|
|
20
|
+
ApiResponse,
|
|
21
|
+
ApiHeader,
|
|
22
|
+
} from '@nestjs/swagger';
|
|
23
|
+
|
|
24
|
+
// Initialize cache if configured
|
|
25
|
+
const redisHost = process.env.REDIS_HOST;
|
|
26
|
+
const cache = redisHost
|
|
27
|
+
? new RedisCache({
|
|
28
|
+
host: redisHost,
|
|
29
|
+
port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : 6379,
|
|
30
|
+
prefix: process.env.REDIS_PREFIX || 'morphql:',
|
|
31
|
+
})
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
export class ExecuteDto {
|
|
35
|
+
@ApiProperty({
|
|
36
|
+
description: 'The MorphQL query string',
|
|
37
|
+
example: 'from json to json transform set name = split(fullName, " ")',
|
|
38
|
+
})
|
|
39
|
+
query!: string;
|
|
40
|
+
|
|
41
|
+
@ApiProperty({
|
|
42
|
+
description: 'The source data to transform',
|
|
43
|
+
example: { fullName: 'John Doe' },
|
|
44
|
+
})
|
|
45
|
+
data!: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class CompileDto {
|
|
49
|
+
@ApiProperty({
|
|
50
|
+
description: 'The MorphQL query string',
|
|
51
|
+
example: 'from json to json transform set name = split(fullName, " ")',
|
|
52
|
+
})
|
|
53
|
+
query!: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ExecuteResponseDto {
|
|
57
|
+
@ApiProperty()
|
|
58
|
+
success!: boolean;
|
|
59
|
+
|
|
60
|
+
@ApiProperty()
|
|
61
|
+
result!: unknown;
|
|
62
|
+
|
|
63
|
+
@ApiProperty()
|
|
64
|
+
executionTime!: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class CompileResponseDto {
|
|
68
|
+
@ApiProperty()
|
|
69
|
+
success!: boolean;
|
|
70
|
+
|
|
71
|
+
@ApiProperty()
|
|
72
|
+
code!: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@ApiTags('Morph Engine')
|
|
76
|
+
@ApiHeader({
|
|
77
|
+
name: 'X-API-KEY',
|
|
78
|
+
description: 'Optional API Key for authentication',
|
|
79
|
+
required: false,
|
|
80
|
+
})
|
|
81
|
+
@Controller('v1')
|
|
82
|
+
@UseGuards(ApiKeyGuard)
|
|
83
|
+
export class MorphController {
|
|
84
|
+
@Post('execute')
|
|
85
|
+
@ApiOperation({ summary: 'Execute a transformation' })
|
|
86
|
+
@ApiResponse({ status: 200, type: ExecuteResponseDto })
|
|
87
|
+
async execute(@Body() body: ExecuteDto): Promise<ExecuteResponseDto> {
|
|
88
|
+
if (!body.query || !body.data) {
|
|
89
|
+
throw new BadRequestException('Missing query or data');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const start = performance.now();
|
|
94
|
+
const engine = await compile(body.query, { cache });
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
96
|
+
const result = await engine(body.data);
|
|
97
|
+
const end = performance.now();
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
success: true,
|
|
101
|
+
result,
|
|
102
|
+
executionTime: end - start,
|
|
103
|
+
};
|
|
104
|
+
} catch (e: unknown) {
|
|
105
|
+
console.error('Execute Error:', e);
|
|
106
|
+
const message =
|
|
107
|
+
e instanceof Error ? e.message : 'Unknown compilation error';
|
|
108
|
+
throw new InternalServerErrorException(message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@Post('compile')
|
|
113
|
+
@ApiOperation({ summary: 'Compile MorphQL to JavaScript' })
|
|
114
|
+
@ApiResponse({ status: 200, type: CompileResponseDto })
|
|
115
|
+
async compile(@Body() body: CompileDto): Promise<CompileResponseDto> {
|
|
116
|
+
if (!body.query) {
|
|
117
|
+
throw new BadRequestException('Missing query');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const engine = await compile(body.query, { cache });
|
|
122
|
+
return {
|
|
123
|
+
success: true,
|
|
124
|
+
code: engine.code,
|
|
125
|
+
};
|
|
126
|
+
} catch (e: unknown) {
|
|
127
|
+
const message =
|
|
128
|
+
e instanceof Error ? e.message : 'Unknown compilation error';
|
|
129
|
+
throw new InternalServerErrorException(message);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@Get('health')
|
|
134
|
+
@ApiOperation({ summary: 'Liveness check' })
|
|
135
|
+
@ApiResponse({ status: 200, description: 'Service is alive' })
|
|
136
|
+
health() {
|
|
137
|
+
return { status: 'ok', timestamp: new Date().toISOString() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Get('health/ready')
|
|
141
|
+
@ApiOperation({ summary: 'Readiness check' })
|
|
142
|
+
@ApiResponse({ status: 200, description: 'Service is ready' })
|
|
143
|
+
@ApiResponse({ status: 503, description: 'Service is not ready' })
|
|
144
|
+
async ready() {
|
|
145
|
+
if (cache && redisHost) {
|
|
146
|
+
const isRedisOk = await cache.ping();
|
|
147
|
+
if (!isRedisOk) {
|
|
148
|
+
throw new ServiceUnavailableException('Redis cache is unavailable');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { status: 'ready', timestamp: new Date().toISOString() };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
4
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
5
|
+
import { INestApplication } from '@nestjs/common';
|
|
6
|
+
import request from 'supertest';
|
|
7
|
+
import { App } from 'supertest/types.js';
|
|
8
|
+
import { AppModule } from './../src/app.module.js';
|
|
9
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
10
|
+
|
|
11
|
+
describe('MorphController (e2e)', () => {
|
|
12
|
+
let app: INestApplication<App>;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
16
|
+
imports: [AppModule],
|
|
17
|
+
}).compile();
|
|
18
|
+
|
|
19
|
+
app = moduleFixture.createNestApplication();
|
|
20
|
+
await app.init();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('Health', () => {
|
|
24
|
+
it('/v1/health (GET)', () => {
|
|
25
|
+
return request(app.getHttpServer())
|
|
26
|
+
.get('/v1/health')
|
|
27
|
+
.expect(200)
|
|
28
|
+
.expect((res) => {
|
|
29
|
+
expect(res.body.status).toBe('ok');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('/v1/health/ready (GET)', () => {
|
|
34
|
+
// Note: Redis might not be running in this test environment,
|
|
35
|
+
// but the controller handles it gracefully if not configured.
|
|
36
|
+
return request(app.getHttpServer())
|
|
37
|
+
.get('/v1/health/ready')
|
|
38
|
+
.expect(200)
|
|
39
|
+
.expect((res) => {
|
|
40
|
+
expect(res.body.status).toBe('ready');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const validQuery =
|
|
46
|
+
'from json to json transform set name = "Hello " + fullName';
|
|
47
|
+
const testData = { fullName: 'John Doe' };
|
|
48
|
+
|
|
49
|
+
it('/v1/compile (POST)', () => {
|
|
50
|
+
return request(app.getHttpServer())
|
|
51
|
+
.post('/v1/compile')
|
|
52
|
+
.send({ query: validQuery })
|
|
53
|
+
.expect(201)
|
|
54
|
+
.expect((res) => {
|
|
55
|
+
expect(res.body.success).toBe(true);
|
|
56
|
+
expect(res.body.code).toBeDefined();
|
|
57
|
+
expect(res.body.code).toContain('function');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('/v1/execute (POST)', () => {
|
|
62
|
+
return request(app.getHttpServer())
|
|
63
|
+
.post('/v1/execute')
|
|
64
|
+
.send({ query: validQuery, data: testData })
|
|
65
|
+
.expect(201)
|
|
66
|
+
.expect((res) => {
|
|
67
|
+
expect(res.body.success).toBe(true);
|
|
68
|
+
const result =
|
|
69
|
+
typeof res.body.result === 'string'
|
|
70
|
+
? JSON.parse(res.body.result)
|
|
71
|
+
: res.body.result;
|
|
72
|
+
expect(result).toEqual({ name: 'Hello John Doe' });
|
|
73
|
+
expect(res.body.executionTime).toBeGreaterThanOrEqual(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('/v1/execute (POST) - Missing Data', () => {
|
|
78
|
+
return request(app.getHttpServer())
|
|
79
|
+
.post('/v1/execute')
|
|
80
|
+
.send({ query: validQuery })
|
|
81
|
+
.expect(400);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "nodenext",
|
|
4
|
+
"moduleResolution": "nodenext",
|
|
5
|
+
"resolvePackageJsonExports": true,
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"isolatedModules": true,
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"removeComments": true,
|
|
10
|
+
"emitDecoratorMetadata": true,
|
|
11
|
+
"experimentalDecorators": true,
|
|
12
|
+
"allowSyntheticDefaultImports": true,
|
|
13
|
+
"target": "ES2023",
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"outDir": "./dist",
|
|
16
|
+
"baseUrl": "./",
|
|
17
|
+
"incremental": true,
|
|
18
|
+
"skipLibCheck": true,
|
|
19
|
+
"strictNullChecks": true,
|
|
20
|
+
"forceConsistentCasingInFileNames": true,
|
|
21
|
+
"noImplicitAny": true,
|
|
22
|
+
"strictBindCallApply": true,
|
|
23
|
+
"noFallthroughCasesInSwitch": true,
|
|
24
|
+
"types": ["vitest/globals", "node"]
|
|
25
|
+
}
|
|
26
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import swc from 'unplugin-swc';
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
globals: true,
|
|
7
|
+
root: './',
|
|
8
|
+
include: ['**/*.e2e-spec.ts', '**/*.spec.ts'],
|
|
9
|
+
},
|
|
10
|
+
plugins: [
|
|
11
|
+
// This is required to build the test files with SWC
|
|
12
|
+
swc.vite({
|
|
13
|
+
module: { type: 'es6' },
|
|
14
|
+
}),
|
|
15
|
+
],
|
|
16
|
+
});
|