@launch77-shared/plugin-graphql-api 0.0.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/README.md +49 -0
- package/dist/generator.js +49 -0
- package/package.json +46 -0
- package/plugin.json +14 -0
- package/templates/src/.gitkeep +0 -0
- package/templates/src/app/api/graphql/route.ts +17 -0
- package/templates/src/app/plugins/graphql-api/page.tsx +296 -0
- package/templates/src/modules/graphql/bootstrap.ts +1 -0
- package/templates/src/modules/graphql/services/schema-svc.ts +22 -0
- package/templates/src/modules/graphql/services/server-svc.ts +24 -0
- package/templates/src/modules/graphql/types/Context.ts +7 -0
- package/templates/src/modules/graphql-example/api/dtos/book-dto.ts +80 -0
- package/templates/src/modules/graphql-example/api/resolvers/book-resolver.ts +39 -0
- package/templates/src/modules/graphql-example/index.ts +2 -0
- package/templates/src/modules/graphql-example/services/book-svc.ts +110 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# GraphqlApi Plugin
|
|
2
|
+
|
|
3
|
+
Launch77 plugin for graphql-api
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
launch77 plugin:install graphql-api
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
After installation, the plugin will:
|
|
14
|
+
|
|
15
|
+
- TODO: Describe what the plugin does
|
|
16
|
+
- TODO: List any files created or modified
|
|
17
|
+
- TODO: Explain configuration options
|
|
18
|
+
|
|
19
|
+
## Development
|
|
20
|
+
|
|
21
|
+
### Building
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm run build
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Testing
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run typecheck
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Template Files
|
|
34
|
+
|
|
35
|
+
The `templates/` directory contains files that will be copied to the target application when this plugin is installed. Add any template files your plugin needs here.
|
|
36
|
+
|
|
37
|
+
## Showcase Page (Optional)
|
|
38
|
+
|
|
39
|
+
To create an examples/documentation page for your plugin:
|
|
40
|
+
|
|
41
|
+
1. Add `"showcaseUrl": "/plugins/"` to `plugin.json`
|
|
42
|
+
2. Create a page at `templates/src/app/plugins//page.tsx`
|
|
43
|
+
3. The page will appear on the `/plugins` discovery page when installed
|
|
44
|
+
|
|
45
|
+
See the [Plugin Development Guide](https://github.com/launch77/docs/plugin-development.md) for details.
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
UNLICENSED
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/generator.ts
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { StandardGenerator } from "@launch77/plugin-runtime";
|
|
6
|
+
var GraphqlApiGenerator = class extends StandardGenerator {
|
|
7
|
+
constructor(context) {
|
|
8
|
+
super(context);
|
|
9
|
+
}
|
|
10
|
+
async injectCode() {
|
|
11
|
+
console.log(chalk.cyan("\u{1F527} Setting up graphql-api plugin...\n"));
|
|
12
|
+
console.log(chalk.green(" \u2713 Plugin setup complete\n"));
|
|
13
|
+
}
|
|
14
|
+
showNextSteps() {
|
|
15
|
+
console.log(chalk.white("\n" + "\u2500".repeat(60) + "\n"));
|
|
16
|
+
console.log(chalk.cyan("\u{1F4CB} GraphqlApi Plugin Installed!\n"));
|
|
17
|
+
console.log(chalk.white("Next Steps:\n"));
|
|
18
|
+
console.log(chalk.gray("1. TODO: Add your first step"));
|
|
19
|
+
console.log(chalk.cyan(" npm run <command>\n"));
|
|
20
|
+
console.log(chalk.gray("2. TODO: Add your second step"));
|
|
21
|
+
console.log(chalk.cyan(" npm run <command>\n"));
|
|
22
|
+
console.log(chalk.white("Documentation:\n"));
|
|
23
|
+
console.log(chalk.gray("See README.md for detailed instructions.\n"));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
async function main() {
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const appPath = args.find((arg) => arg.startsWith("--appPath="))?.split("=")[1];
|
|
29
|
+
const appName = args.find((arg) => arg.startsWith("--appName="))?.split("=")[1];
|
|
30
|
+
const workspaceName = args.find((arg) => arg.startsWith("--workspaceName="))?.split("=")[1];
|
|
31
|
+
const pluginPath = args.find((arg) => arg.startsWith("--pluginPath="))?.split("=")[1];
|
|
32
|
+
if (!appPath || !appName || !workspaceName || !pluginPath) {
|
|
33
|
+
console.error(chalk.red("Error: Missing required arguments"));
|
|
34
|
+
console.error(chalk.gray("Usage: --appPath=<path> --appName=<name> --workspaceName=<name> --pluginPath=<path>"));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const generator = new GraphqlApiGenerator({ appPath, appName, workspaceName, pluginPath });
|
|
38
|
+
await generator.run();
|
|
39
|
+
}
|
|
40
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
41
|
+
main().catch((error) => {
|
|
42
|
+
console.error(chalk.red("\n\u274C Error during plugin setup:"));
|
|
43
|
+
console.error(error);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
GraphqlApiGenerator
|
|
49
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@launch77-shared/plugin-graphql-api",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Launch77 plugin for graphql-api",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/generator.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"generate": "./dist/generator.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/",
|
|
13
|
+
"templates/",
|
|
14
|
+
"plugin.json"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"prebuild": "node scripts/validate-plugin-json.js",
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"dev": "tsup --watch",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"release:connect": "launch77-release-connect",
|
|
22
|
+
"release:verify": "launch77-release-verify"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@launch77/plugin-runtime": "^0.3.2",
|
|
26
|
+
"chalk": "^5.3.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.10.0",
|
|
30
|
+
"tsup": "^8.0.0",
|
|
31
|
+
"typescript": "^5.3.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"launch77": {
|
|
37
|
+
"installedPlugins": {
|
|
38
|
+
"release": {
|
|
39
|
+
"package": "release",
|
|
40
|
+
"version": "1.1.1",
|
|
41
|
+
"installedAt": "2026-02-06T07:36:14.984Z",
|
|
42
|
+
"source": "local"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"targets": ["app"],
|
|
3
|
+
"showcaseUrl": "/plugins/graphql-api",
|
|
4
|
+
"pluginDependencies": {},
|
|
5
|
+
"libraryDependencies": {
|
|
6
|
+
"@apollo/server": "^4.11.0",
|
|
7
|
+
"graphql": "^16.9.0",
|
|
8
|
+
"@as-integrations/next": "^3.2.0",
|
|
9
|
+
"type-graphql": "^2.0.0-rc.2",
|
|
10
|
+
"class-validator": "^0.14.1",
|
|
11
|
+
"class-transformer": "^0.5.1",
|
|
12
|
+
"reflect-metadata": "^0.2.2"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { startServerAndCreateNextHandler } from '@as-integrations/next'
|
|
2
|
+
import { NextRequest } from 'next/server'
|
|
3
|
+
import { getServerSingleton } from '@/modules/graphql/services/server-svc'
|
|
4
|
+
|
|
5
|
+
export const runtime = 'nodejs'
|
|
6
|
+
|
|
7
|
+
const handlerPromise = getServerSingleton().then((server) => startServerAndCreateNextHandler<NextRequest>(server))
|
|
8
|
+
|
|
9
|
+
export async function GET(req: NextRequest) {
|
|
10
|
+
const handler = await handlerPromise
|
|
11
|
+
return handler(req)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function POST(req: NextRequest) {
|
|
15
|
+
const handler = await handlerPromise
|
|
16
|
+
return handler(req)
|
|
17
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { ExternalLink, Copy, Check } from 'lucide-react'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
|
|
7
|
+
// Simple CodeBlock component
|
|
8
|
+
function CodeBlock({ code, language }: { code: string; language: string }) {
|
|
9
|
+
const [copied, setCopied] = useState(false)
|
|
10
|
+
|
|
11
|
+
const handleCopy = () => {
|
|
12
|
+
navigator.clipboard.writeText(code)
|
|
13
|
+
setCopied(true)
|
|
14
|
+
setTimeout(() => setCopied(false), 2000)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="relative group">
|
|
19
|
+
<pre className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 overflow-x-auto text-sm">
|
|
20
|
+
<code className={`language-${language}`}>{code}</code>
|
|
21
|
+
</pre>
|
|
22
|
+
<button onClick={handleCopy} className="absolute top-2 right-2 p-2 bg-white dark:bg-gray-700 rounded-md shadow-sm opacity-0 group-hover:opacity-100 transition-opacity" title="Copy to clipboard">
|
|
23
|
+
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4 text-gray-600" />}
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Simple InlineCode component
|
|
30
|
+
function InlineCode({ children }: { children: React.ReactNode }) {
|
|
31
|
+
return <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">{children}</code>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function GraphQLAPIShowcasePage() {
|
|
35
|
+
return (
|
|
36
|
+
<div className="container mx-auto max-w-6xl py-12 px-4">
|
|
37
|
+
{/* Header */}
|
|
38
|
+
<div className="mb-12">
|
|
39
|
+
<h1 className="text-4xl font-bold mb-4">GraphQL API Plugin</h1>
|
|
40
|
+
<p className="text-gray-600 dark:text-gray-400 text-lg">Type-safe GraphQL API with Apollo Server and TypeGraphQL</p>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div className="space-y-12">
|
|
44
|
+
{/* Apollo Sandbox - Hero Section */}
|
|
45
|
+
<section className="mb-12">
|
|
46
|
+
<h2 className="text-2xl font-bold mb-4">Apollo Sandbox</h2>
|
|
47
|
+
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 mb-6">
|
|
48
|
+
<p className="text-gray-700 dark:text-gray-300 mb-4">Test and explore your GraphQL API interactively with Apollo Sandbox. It provides a powerful IDE with autocomplete, query history, and schema documentation.</p>
|
|
49
|
+
<a href="/api/graphql" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors font-semibold">
|
|
50
|
+
Open Apollo Sandbox
|
|
51
|
+
<ExternalLink className="w-4 h-4" />
|
|
52
|
+
</a>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
|
55
|
+
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
|
56
|
+
<strong>Note:</strong> Apollo Sandbox opens at <InlineCode>/api/graphql</InlineCode> and requires the development server to be running.
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
61
|
+
{/* Overview Section */}
|
|
62
|
+
<section>
|
|
63
|
+
<h2 className="text-2xl font-bold mb-4">Overview</h2>
|
|
64
|
+
<p className="text-gray-600 dark:text-gray-400 mb-6">This plugin provides a fully-featured GraphQL API using:</p>
|
|
65
|
+
<ul className="list-disc list-inside space-y-2 text-gray-700 dark:text-gray-300 mb-6">
|
|
66
|
+
<li>
|
|
67
|
+
<strong>Apollo Server</strong> - Industry-standard GraphQL server
|
|
68
|
+
</li>
|
|
69
|
+
<li>
|
|
70
|
+
<strong>TypeGraphQL</strong> - Build type-safe GraphQL APIs with TypeScript decorators
|
|
71
|
+
</li>
|
|
72
|
+
<li>
|
|
73
|
+
<strong>Next.js Integration</strong> - Seamlessly integrated with Next.js App Router
|
|
74
|
+
</li>
|
|
75
|
+
<li>
|
|
76
|
+
<strong>Class Validator</strong> - Automatic input validation
|
|
77
|
+
</li>
|
|
78
|
+
<li>
|
|
79
|
+
<strong>Example Book API</strong> - Ready-to-use CRUD operations
|
|
80
|
+
</li>
|
|
81
|
+
</ul>
|
|
82
|
+
</section>
|
|
83
|
+
|
|
84
|
+
{/* Example Queries Section */}
|
|
85
|
+
<section>
|
|
86
|
+
<h2 className="text-2xl font-bold mb-4">Example Queries</h2>
|
|
87
|
+
<p className="text-gray-600 dark:text-gray-400 mb-6">Copy these queries and run them in Apollo Sandbox to test the API:</p>
|
|
88
|
+
|
|
89
|
+
{/* List Books */}
|
|
90
|
+
<div className="mb-8">
|
|
91
|
+
<h3 className="text-xl font-semibold mb-3">List Books</h3>
|
|
92
|
+
<p className="text-gray-600 dark:text-gray-400 mb-3">Fetch all books with optional search and sorting:</p>
|
|
93
|
+
<CodeBlock
|
|
94
|
+
language="graphql"
|
|
95
|
+
code={`query ListBooks {
|
|
96
|
+
books {
|
|
97
|
+
id
|
|
98
|
+
title
|
|
99
|
+
author
|
|
100
|
+
description
|
|
101
|
+
createdAt
|
|
102
|
+
updatedAt
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# With search
|
|
107
|
+
query SearchBooks {
|
|
108
|
+
books(search: "Clean") {
|
|
109
|
+
id
|
|
110
|
+
title
|
|
111
|
+
author
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# With sorting
|
|
116
|
+
query SortedBooks {
|
|
117
|
+
books(sort: { field: TITLE, direction: ASC }) {
|
|
118
|
+
id
|
|
119
|
+
title
|
|
120
|
+
author
|
|
121
|
+
}
|
|
122
|
+
}`}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* Get Single Book */}
|
|
127
|
+
<div className="mb-8">
|
|
128
|
+
<h3 className="text-xl font-semibold mb-3">Get Single Book</h3>
|
|
129
|
+
<p className="text-gray-600 dark:text-gray-400 mb-3">Fetch a specific book by ID:</p>
|
|
130
|
+
<CodeBlock
|
|
131
|
+
language="graphql"
|
|
132
|
+
code={`query GetBook {
|
|
133
|
+
book(id: "1") {
|
|
134
|
+
id
|
|
135
|
+
title
|
|
136
|
+
author
|
|
137
|
+
description
|
|
138
|
+
createdAt
|
|
139
|
+
updatedAt
|
|
140
|
+
}
|
|
141
|
+
}`}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{/* Create Book */}
|
|
146
|
+
<div className="mb-8">
|
|
147
|
+
<h3 className="text-xl font-semibold mb-3">Create Book</h3>
|
|
148
|
+
<p className="text-gray-600 dark:text-gray-400 mb-3">Create a new book with validation:</p>
|
|
149
|
+
<CodeBlock
|
|
150
|
+
language="graphql"
|
|
151
|
+
code={`mutation CreateBook {
|
|
152
|
+
createBook(input: {
|
|
153
|
+
title: "Design Patterns"
|
|
154
|
+
author: "Gang of Four"
|
|
155
|
+
description: "Elements of Reusable Object-Oriented Software"
|
|
156
|
+
}) {
|
|
157
|
+
id
|
|
158
|
+
title
|
|
159
|
+
author
|
|
160
|
+
description
|
|
161
|
+
createdAt
|
|
162
|
+
}
|
|
163
|
+
}`}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Update Book */}
|
|
168
|
+
<div className="mb-8">
|
|
169
|
+
<h3 className="text-xl font-semibold mb-3">Update Book</h3>
|
|
170
|
+
<p className="text-gray-600 dark:text-gray-400 mb-3">Update an existing book (all fields optional):</p>
|
|
171
|
+
<CodeBlock
|
|
172
|
+
language="graphql"
|
|
173
|
+
code={`mutation UpdateBook {
|
|
174
|
+
updateBook(id: "1", input: {
|
|
175
|
+
description: "Updated description for the book"
|
|
176
|
+
}) {
|
|
177
|
+
id
|
|
178
|
+
title
|
|
179
|
+
author
|
|
180
|
+
description
|
|
181
|
+
updatedAt
|
|
182
|
+
}
|
|
183
|
+
}`}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* Delete Book */}
|
|
188
|
+
<div className="mb-8">
|
|
189
|
+
<h3 className="text-xl font-semibold mb-3">Delete Book</h3>
|
|
190
|
+
<p className="text-gray-600 dark:text-gray-400 mb-3">Delete a book by ID:</p>
|
|
191
|
+
<CodeBlock
|
|
192
|
+
language="graphql"
|
|
193
|
+
code={`mutation DeleteBook {
|
|
194
|
+
deleteBook(id: "1")
|
|
195
|
+
}`}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</section>
|
|
199
|
+
|
|
200
|
+
{/* Extending the API */}
|
|
201
|
+
<section>
|
|
202
|
+
<h2 className="text-2xl font-bold mb-4">Extending the API</h2>
|
|
203
|
+
<p className="text-gray-600 dark:text-gray-400 mb-6">Add your own GraphQL resolvers to extend the API:</p>
|
|
204
|
+
|
|
205
|
+
<div className="mb-6">
|
|
206
|
+
<h3 className="text-lg font-semibold mb-3">1. Create a DTO (Data Transfer Object)</h3>
|
|
207
|
+
<CodeBlock
|
|
208
|
+
language="typescript"
|
|
209
|
+
code={`import { ObjectType, Field, ID, InputType } from 'type-graphql'
|
|
210
|
+
import { IsString, MinLength, MaxLength } from 'class-validator'
|
|
211
|
+
|
|
212
|
+
@ObjectType()
|
|
213
|
+
export class ProductDTO {
|
|
214
|
+
@Field(() => ID)
|
|
215
|
+
id!: string
|
|
216
|
+
|
|
217
|
+
@Field(() => String)
|
|
218
|
+
name!: string
|
|
219
|
+
|
|
220
|
+
@Field(() => Number)
|
|
221
|
+
price!: number
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@InputType()
|
|
225
|
+
export class CreateProductInput {
|
|
226
|
+
@Field(() => String)
|
|
227
|
+
@IsString()
|
|
228
|
+
@MinLength(1)
|
|
229
|
+
@MaxLength(100)
|
|
230
|
+
name!: string
|
|
231
|
+
|
|
232
|
+
@Field(() => Number)
|
|
233
|
+
price!: number
|
|
234
|
+
}`}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div className="mb-6">
|
|
239
|
+
<h3 className="text-lg font-semibold mb-3">2. Create a Resolver</h3>
|
|
240
|
+
<CodeBlock
|
|
241
|
+
language="typescript"
|
|
242
|
+
code={`import { Resolver, Query, Mutation, Arg, Ctx } from 'type-graphql'
|
|
243
|
+
import { ProductDTO, CreateProductInput } from '../dtos/product-dto'
|
|
244
|
+
import type { Context } from '@/modules/graphql/types/Context'
|
|
245
|
+
|
|
246
|
+
@Resolver(() => ProductDTO)
|
|
247
|
+
export class ProductResolver {
|
|
248
|
+
@Query(() => [ProductDTO])
|
|
249
|
+
async products(@Ctx() ctx: Context): Promise<ProductDTO[]> {
|
|
250
|
+
// Your logic here
|
|
251
|
+
return []
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@Mutation(() => ProductDTO)
|
|
255
|
+
async createProduct(
|
|
256
|
+
@Arg('input') input: CreateProductInput,
|
|
257
|
+
@Ctx() ctx: Context
|
|
258
|
+
): Promise<ProductDTO> {
|
|
259
|
+
// Your logic here
|
|
260
|
+
return {
|
|
261
|
+
id: '1',
|
|
262
|
+
name: input.name,
|
|
263
|
+
price: input.price
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}`}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div className="mb-6">
|
|
271
|
+
<h3 className="text-lg font-semibold mb-3">3. Register Your Resolver</h3>
|
|
272
|
+
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
|
273
|
+
Add your resolver to the resolvers array in <InlineCode>src/modules/graphql/services/schema-svc.ts</InlineCode>:
|
|
274
|
+
</p>
|
|
275
|
+
<CodeBlock
|
|
276
|
+
language="typescript"
|
|
277
|
+
code={`import { BookResolver } from '@/modules/graphql-example'
|
|
278
|
+
import { ProductResolver } from '@/modules/products' // Your new resolver
|
|
279
|
+
|
|
280
|
+
const resolverClasses = [
|
|
281
|
+
BookResolver,
|
|
282
|
+
ProductResolver, // Add your resolver here
|
|
283
|
+
] as const`}
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mt-6">
|
|
288
|
+
<p className="text-sm text-green-800 dark:text-green-200">
|
|
289
|
+
<strong>TypeScript Configuration:</strong> This plugin requires <InlineCode>experimentalDecorators</InlineCode>, <InlineCode>emitDecoratorMetadata</InlineCode>, and <InlineCode>useDefineForClassFields: false</InlineCode> in your tsconfig.json.
|
|
290
|
+
</p>
|
|
291
|
+
</div>
|
|
292
|
+
</section>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import '@/modules/graphql/bootstrap' // imports reflect-metadata once
|
|
2
|
+
|
|
3
|
+
import { buildSchema } from 'type-graphql'
|
|
4
|
+
import type { GraphQLSchema } from 'graphql'
|
|
5
|
+
|
|
6
|
+
import { graphqlResolvers as exampleResolvers } from '@/modules/graphql-example'
|
|
7
|
+
|
|
8
|
+
const resolverClasses = [...exampleResolvers] as const
|
|
9
|
+
|
|
10
|
+
const schemaPromise: Promise<GraphQLSchema> = buildSchema({
|
|
11
|
+
resolvers: resolverClasses,
|
|
12
|
+
validate: {
|
|
13
|
+
whitelist: true,
|
|
14
|
+
forbidNonWhitelisted: true,
|
|
15
|
+
skipMissingProperties: false,
|
|
16
|
+
},
|
|
17
|
+
emitSchemaFile: false,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export function getSchema(): Promise<GraphQLSchema> {
|
|
21
|
+
return schemaPromise
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import '@/modules/graphql/bootstrap'
|
|
2
|
+
import { ApolloServer } from '@apollo/server'
|
|
3
|
+
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'
|
|
4
|
+
import type { Context } from '../types/Context'
|
|
5
|
+
import { getSchema } from './schema-svc'
|
|
6
|
+
|
|
7
|
+
let serverInstance: ApolloServer<Context> | null = null
|
|
8
|
+
const isProd = process.env.VERCEL_ENV === 'production'
|
|
9
|
+
|
|
10
|
+
export async function getServerSingleton() {
|
|
11
|
+
if (!serverInstance) serverInstance = await createServerInstance()
|
|
12
|
+
return serverInstance
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function createServerInstance() {
|
|
16
|
+
const plugins = []
|
|
17
|
+
if (!isProd) plugins.push(ApolloServerPluginLandingPageLocalDefault({ embed: true, footer: false }))
|
|
18
|
+
const schema = await getSchema()
|
|
19
|
+
return new ApolloServer<Context>({
|
|
20
|
+
introspection: !isProd,
|
|
21
|
+
plugins,
|
|
22
|
+
schema,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL Context type definition.
|
|
3
|
+
* This file defines the structure of the context object that will be passed to all GraphQL resolvers.
|
|
4
|
+
* The context can include things like authentication information, database connections, data loaders, etc.
|
|
5
|
+
*
|
|
6
|
+
*/
|
|
7
|
+
export interface Context {}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Field, ID, InputType, ObjectType } from 'type-graphql'
|
|
2
|
+
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
|
|
3
|
+
|
|
4
|
+
@ObjectType({ description: 'A book in the in-memory example catalog' })
|
|
5
|
+
export class BookDTO {
|
|
6
|
+
@Field(() => ID)
|
|
7
|
+
id!: string
|
|
8
|
+
|
|
9
|
+
@Field(() => String)
|
|
10
|
+
title!: string
|
|
11
|
+
|
|
12
|
+
@Field(() => String)
|
|
13
|
+
author!: string
|
|
14
|
+
|
|
15
|
+
@Field(() => String, { nullable: true })
|
|
16
|
+
description?: string
|
|
17
|
+
|
|
18
|
+
@Field(() => String)
|
|
19
|
+
createdAt!: string
|
|
20
|
+
|
|
21
|
+
@Field(() => String)
|
|
22
|
+
updatedAt!: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@InputType({ description: 'Input for creating a book' })
|
|
26
|
+
export class CreateBookInput {
|
|
27
|
+
@Field(() => String)
|
|
28
|
+
@IsString()
|
|
29
|
+
@MinLength(1)
|
|
30
|
+
@MaxLength(120)
|
|
31
|
+
title!: string
|
|
32
|
+
|
|
33
|
+
@Field(() => String)
|
|
34
|
+
@IsString()
|
|
35
|
+
@MinLength(1)
|
|
36
|
+
@MaxLength(80)
|
|
37
|
+
author!: string
|
|
38
|
+
|
|
39
|
+
@Field(() => String, { nullable: true })
|
|
40
|
+
@IsOptional()
|
|
41
|
+
@IsString()
|
|
42
|
+
@MaxLength(500)
|
|
43
|
+
description?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@InputType({ description: 'Input for updating a book' })
|
|
47
|
+
export class UpdateBookInput {
|
|
48
|
+
@Field(() => String, { nullable: true })
|
|
49
|
+
@IsOptional()
|
|
50
|
+
@IsString()
|
|
51
|
+
@MinLength(1)
|
|
52
|
+
@MaxLength(120)
|
|
53
|
+
title?: string
|
|
54
|
+
|
|
55
|
+
@Field(() => String, { nullable: true })
|
|
56
|
+
@IsOptional()
|
|
57
|
+
@IsString()
|
|
58
|
+
@MinLength(1)
|
|
59
|
+
@MaxLength(80)
|
|
60
|
+
author?: string
|
|
61
|
+
|
|
62
|
+
@Field(() => String, { nullable: true })
|
|
63
|
+
@IsOptional()
|
|
64
|
+
@IsString()
|
|
65
|
+
@MaxLength(500)
|
|
66
|
+
description?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@InputType({ description: 'Sort order for listing books' })
|
|
70
|
+
export class BookSortInput {
|
|
71
|
+
@Field(() => String, { nullable: true, description: 'Sort field: title|author|createdAt|updatedAt' })
|
|
72
|
+
@IsOptional()
|
|
73
|
+
@IsIn(['title', 'author', 'createdAt', 'updatedAt'])
|
|
74
|
+
field?: 'title' | 'author' | 'createdAt' | 'updatedAt'
|
|
75
|
+
|
|
76
|
+
@Field(() => String, { nullable: true, description: 'Sort direction: asc|desc' })
|
|
77
|
+
@IsOptional()
|
|
78
|
+
@IsIn(['asc', 'desc'])
|
|
79
|
+
direction?: 'asc' | 'desc'
|
|
80
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Arg, Ctx, ID, Mutation, Query, Resolver } from 'type-graphql'
|
|
2
|
+
import type { Context } from '@/modules/graphql/types/Context'
|
|
3
|
+
|
|
4
|
+
import { BookDTO, BookSortInput, CreateBookInput, UpdateBookInput } from '../dtos/book-dto'
|
|
5
|
+
import { bookService } from '../../services/book-svc'
|
|
6
|
+
|
|
7
|
+
@Resolver(() => BookDTO)
|
|
8
|
+
export class BookResolver {
|
|
9
|
+
@Query(() => [BookDTO], { description: 'List books (optionally search & sort)' })
|
|
10
|
+
async books(@Arg('search', () => String, { nullable: true }) search: string | null, @Arg('sort', () => BookSortInput, { nullable: true }) sort: BookSortInput | null, @Ctx() _ctx: Context): Promise<BookDTO[]> {
|
|
11
|
+
return bookService.list({
|
|
12
|
+
search: search ?? undefined,
|
|
13
|
+
sortField: sort?.field,
|
|
14
|
+
sortDirection: sort?.direction ?? undefined,
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Query(() => BookDTO, { nullable: true, description: 'Get a book by id' })
|
|
19
|
+
async book(@Arg('id', () => ID) id: string, @Ctx() _ctx: Context): Promise<BookDTO | null> {
|
|
20
|
+
return bookService.getById(id)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Mutation(() => BookDTO, { description: 'Create a new book' })
|
|
24
|
+
async createBook(@Arg('input', () => CreateBookInput) input: CreateBookInput, @Ctx() _ctx: Context): Promise<BookDTO> {
|
|
25
|
+
return bookService.create(input)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Mutation(() => BookDTO, { description: 'Update an existing book' })
|
|
29
|
+
async updateBook(@Arg('id', () => ID) id: string, @Arg('input', () => UpdateBookInput) input: UpdateBookInput, @Ctx() _ctx: Context): Promise<BookDTO> {
|
|
30
|
+
// for the simple demo, we throw a generic error if missing
|
|
31
|
+
// later you can swap to your NotFoundError / BusinessError types
|
|
32
|
+
return bookService.update(id, input)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Mutation(() => Boolean, { description: 'Delete a book by id' })
|
|
36
|
+
async deleteBook(@Arg('id', () => ID) id: string, @Ctx() _ctx: Context): Promise<boolean> {
|
|
37
|
+
return bookService.delete(id)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { CreateBookInput, UpdateBookInput } from '../api/dtos/book-dto'
|
|
2
|
+
|
|
3
|
+
export type Book = {
|
|
4
|
+
id: string
|
|
5
|
+
title: string
|
|
6
|
+
author: string
|
|
7
|
+
description?: string
|
|
8
|
+
createdAt: string
|
|
9
|
+
updatedAt: string
|
|
10
|
+
[key: string]: string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function nowIso(): string {
|
|
14
|
+
return new Date().toISOString()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function randomId(): string {
|
|
18
|
+
// good enough for demo; replace with uuid later
|
|
19
|
+
return Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class BookService {
|
|
23
|
+
private books: Book[] = [
|
|
24
|
+
{
|
|
25
|
+
id: 'b1',
|
|
26
|
+
title: 'The Pragmatic Programmer',
|
|
27
|
+
author: 'Andrew Hunt & David Thomas',
|
|
28
|
+
description: 'A classic book on pragmatic software craftsmanship.',
|
|
29
|
+
createdAt: nowIso(),
|
|
30
|
+
updatedAt: nowIso(),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'b2',
|
|
34
|
+
title: 'Clean Code',
|
|
35
|
+
author: 'Robert C. Martin',
|
|
36
|
+
description: 'Guidance on writing clean, maintainable code.',
|
|
37
|
+
createdAt: nowIso(),
|
|
38
|
+
updatedAt: nowIso(),
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
list(args?: { search?: string; sortField?: string; sortDirection?: 'asc' | 'desc' }): Book[] {
|
|
43
|
+
const search = args?.search?.trim().toLowerCase()
|
|
44
|
+
let result = [...this.books]
|
|
45
|
+
|
|
46
|
+
if (search) {
|
|
47
|
+
result = result.filter((b) => {
|
|
48
|
+
return b.title.toLowerCase().includes(search) || b.author.toLowerCase().includes(search) || (b.description ?? '').toLowerCase().includes(search)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sortField = args?.sortField ?? 'updatedAt'
|
|
53
|
+
const sortDirection = args?.sortDirection ?? 'desc'
|
|
54
|
+
|
|
55
|
+
result.sort((a: Book, b: Book) => {
|
|
56
|
+
const av = a[sortField] ?? ''
|
|
57
|
+
const bv = b[sortField] ?? ''
|
|
58
|
+
if (av === bv) return 0
|
|
59
|
+
const cmp = av > bv ? 1 : -1
|
|
60
|
+
return sortDirection === 'asc' ? cmp : -cmp
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return result
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getById(id: string): Book | null {
|
|
67
|
+
return this.books.find((b) => b.id === id) ?? null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
create(input: CreateBookInput): Book {
|
|
71
|
+
const ts = nowIso()
|
|
72
|
+
const book: Book = {
|
|
73
|
+
id: randomId(),
|
|
74
|
+
title: input.title.trim(),
|
|
75
|
+
author: input.author.trim(),
|
|
76
|
+
description: input.description?.trim() || undefined,
|
|
77
|
+
createdAt: ts,
|
|
78
|
+
updatedAt: ts,
|
|
79
|
+
}
|
|
80
|
+
this.books.unshift(book)
|
|
81
|
+
return book
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
update(id: string, input: UpdateBookInput): Book {
|
|
85
|
+
const idx = this.books.findIndex((b) => b.id === id)
|
|
86
|
+
if (idx === -1) {
|
|
87
|
+
throw new Error(`Book not found: ${id}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const existing = this.books[idx]
|
|
91
|
+
const updated: Book = {
|
|
92
|
+
...existing,
|
|
93
|
+
title: input.title?.trim() ?? existing.title,
|
|
94
|
+
author: input.author?.trim() ?? existing.author,
|
|
95
|
+
description: input.description === undefined ? existing.description : input.description?.trim() || undefined,
|
|
96
|
+
updatedAt: nowIso(),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.books[idx] = updated
|
|
100
|
+
return updated
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
delete(id: string): boolean {
|
|
104
|
+
const before = this.books.length
|
|
105
|
+
this.books = this.books.filter((b) => b.id !== id)
|
|
106
|
+
return this.books.length !== before
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const bookService = new BookService()
|