@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 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,2 @@
1
+ import { BookResolver } from './api/resolvers/book-resolver'
2
+ export const graphqlResolvers = [BookResolver] as const
@@ -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()