diff --git a/.changepacks/changepack_log_4duOtJPsRLtmWBlDS9xSR.json b/.changepacks/changepack_log_4duOtJPsRLtmWBlDS9xSR.json new file mode 100644 index 0000000..0469615 --- /dev/null +++ b/.changepacks/changepack_log_4duOtJPsRLtmWBlDS9xSR.json @@ -0,0 +1 @@ +{"changes":{"packages/webpack-plugin/package.json":"Patch","packages/fetch/package.json":"Patch","packages/next-plugin/package.json":"Patch","packages/vite-plugin/package.json":"Patch","packages/rsbuild-plugin/package.json":"Patch","packages/core/package.json":"Patch","packages/utils/package.json":"Patch","packages/generator/package.json":"Patch"},"note":"Gen server code","date":"2026-04-27T20:38:10.725587800Z"} \ No newline at end of file diff --git a/README.md b/README.md index 16c3534..b468b64 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Just write API calls — the types are already there. - [Packages](#-packages) - [API Usage](#-api-usage) - [Multiple API Servers](#-multiple-api-servers) +- [Next.js Server Actions](#-nextjs-server-actions) - [React Query Integration](#-react-query-integration) - [Advanced Usage](#-advanced-usage) - [Configuration Options](#-configuration-options) @@ -64,6 +65,11 @@ devup-api feels like using `fetch`, but with superpowers: - Automatic type generation during build time - Zero runtime overhead +### **⚡ Generated Next.js Server Actions** +- Generate top-level Server Action functions from OpenAPI operationIds +- Import actions from `@devup-api/fetch/server` instead of reaching into `df` +- Cold typing works before generated files exist, then becomes fully typed after the plugin runs + --- ## 🚀 Quick Start @@ -714,6 +720,57 @@ type Product = DevupObject<'response', 'openapi2.json'>['Product'] // From open --- +## ⚡ Next.js Server Actions + +devup-api generates named Server Action wrappers for operationId-based API calls by default. This is useful in Next.js App Router projects when you want to call server-side API functions from Client Components without manually writing one action per endpoint. + +Set `serverActions.baseUrl` when generated actions should call a specific API origin: + +```ts +// next.config.ts +import devupApi from '@devup-api/next-plugin' + +export default devupApi({ + reactStrictMode: true, + serverActions: { + baseUrl: 'https://api.example.com', + }, +}) +``` + +Then import generated actions from the virtual server module: + +```tsx +'use client' + +import { getUser } from '@devup-api/fetch/server' + +export function UserButton() { + return ( + + ) +} +``` + +The generated `df/server.ts` file contains `'use server'` and exports one named async function for every operationId in your OpenAPI schemas. You should import from `@devup-api/fetch/server`, not from `df/server.ts` directly; the build plugin aliases that module to the generated file. + +Generated actions return `DevupApiResponse`. This keeps the same `data` / `error` / `isOk` / `isError` shape as normal `api.get()` calls, while replacing the native `Response` instance with a plain serializable response object that can cross the Server Action boundary. + +During cold typing, `@devup-api/fetch/server` is still importable before `df` exists. The fallback keeps initial setup from failing, and the generated module replaces it with strict operation-specific types after `dev` or `build` runs. + +Server Actions are enabled by default. Disable generation explicitly with `serverActions: false` or `serverActions: { enabled: false }`. + +--- + ## 🔄 React Query Integration devup-api provides first-class support for TanStack React Query through the `@devup-api/react-query` package. All hooks are fully typed based on your OpenAPI schema. @@ -1531,6 +1588,16 @@ interface DevupApiOptions { * @default true */ responseDefaultNonNullable?: boolean + + /** + * Generate operationId-based Server Action wrappers and expose them via + * @devup-api/fetch/server. + * @default true + */ + serverActions?: boolean | { + enabled?: boolean + baseUrl?: string + } } ``` @@ -1542,7 +1609,8 @@ interface DevupApiOptions { 2. Extracts paths, methods, schemas, parameters, and request bodies 3. Generates TypeScript interface definitions automatically 4. Creates a URL map for operationId-based API calls -5. Builds a typed wrapper around `fetch()` with full type safety +5. Generates named Server Actions in `df/server.ts` by default +6. Builds a typed wrapper around `fetch()` with full type safety --- diff --git a/SKILL.md b/SKILL.md index 10632db..af89d7e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -150,6 +150,43 @@ api.use({ --- +## @devup-api/fetch/server — Next.js Server Actions + +Server Actions are generated by default. Set `serverActions.baseUrl` when generated actions should call a specific API origin. + +```ts +// next.config.ts +import devupApi from '@devup-api/next-plugin' + +export default devupApi({ + reactStrictMode: true, + serverActions: { + baseUrl: 'https://api.example.com', + }, +}) +``` + +Use generated actions through the virtual module, never by importing `df/server.ts` directly: + +```tsx +'use client' + +import { getUser } from '@devup-api/fetch/server' + +const result = await getUser({ params: { id: '123' } }) +``` + +Notes: + +- Generated `df/server.ts` contains `'use server'` and top-level named async exports. +- Generated actions return `DevupApiResponse`. +- `@devup-api/fetch/server` has a cold typing fallback before `df` exists. +- The plugin aliases `@devup-api/fetch/server` to generated `df/server.ts` during dev/build. +- When enabled, every operationId is generated as a named Server Action export. +- Disable generation explicitly with `serverActions: false` or `serverActions: { enabled: false }`. + +--- + ## @devup-api/react-query — React Query Hooks ```ts @@ -495,6 +532,11 @@ interface DevupApiOptions { convertCase?: 'snake' | 'camel' | 'pascal' | 'maintain' // default: 'camel' requestDefaultNonNullable?: boolean // default: false responseDefaultNonNullable?: boolean // default: true + // default: true; use false or { enabled: false } to disable + serverActions?: boolean | { + enabled?: boolean + baseUrl?: string + } } ``` @@ -531,6 +573,7 @@ const api = createApi(import.meta.env.VITE_API_URL || 'http://localhost:3000') | Issue | Solution | |-------|----------| | Types not appearing | Run `npm run dev`, check tsconfig includes `df/**/*.d.ts` | +| Server Action import fails at runtime | Configure the build plugin so `@devup-api/fetch/server` aliases to generated `df/server.ts` | | operationId not found | Use path `/users/{id}` or verify openapi.json operationId | | Zod schemas empty | Ensure bundler plugin is configured, run dev server | | CRUD config missing | Add `devup:{name}:one` and `devup:{name}:create` tags to OpenAPI | diff --git a/bun.lock b/bun.lock index b7ad6b7..8142c31 100644 --- a/bun.lock +++ b/bun.lock @@ -96,7 +96,7 @@ }, "packages/core": { "name": "@devup-api/core", - "version": "0.1.15", + "version": "0.1.17", "devDependencies": { "@types/node": "^25.5", "typescript": "^6.0", @@ -104,7 +104,7 @@ }, "packages/fetch": { "name": "@devup-api/fetch", - "version": "0.1.19", + "version": "0.1.20", "dependencies": { "@devup-api/core": "workspace:^", }, @@ -115,7 +115,7 @@ }, "packages/generator": { "name": "@devup-api/generator", - "version": "0.1.21", + "version": "0.1.23", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/utils": "workspace:^", @@ -128,7 +128,7 @@ }, "packages/hookform": { "name": "@devup-api/hookform", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@devup-api/fetch": "workspace:^", "@devup-api/zod": "workspace:^", @@ -159,7 +159,7 @@ }, "packages/next-plugin": { "name": "@devup-api/next-plugin", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/generator": "workspace:^", @@ -178,7 +178,7 @@ }, "packages/react-query": { "name": "@devup-api/react-query", - "version": "0.1.12", + "version": "0.1.13", "dependencies": { "@devup-api/fetch": "workspace:^", "@tanstack/react-query": ">=5.96", @@ -197,7 +197,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-api/rsbuild-plugin", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/generator": "workspace:^", @@ -214,7 +214,7 @@ }, "packages/ui": { "name": "@devup-api/ui", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@devup-api/hookform": "workspace:^", }, @@ -233,7 +233,7 @@ }, "packages/utils": { "name": "@devup-api/utils", - "version": "0.1.8", + "version": "0.1.9", "devDependencies": { "@types/node": "^25.5", "openapi-types": "^12.1", @@ -242,7 +242,7 @@ }, "packages/vite-plugin": { "name": "@devup-api/vite-plugin", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/generator": "workspace:^", @@ -260,7 +260,7 @@ }, "packages/webpack-plugin": { "name": "@devup-api/webpack-plugin", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/generator": "workspace:^", @@ -277,7 +277,7 @@ }, "packages/zod": { "name": "@devup-api/zod", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@devup-api/fetch": "workspace:^", "zod": ">=4", diff --git a/examples/next/app/page.tsx b/examples/next/app/page.tsx index 90665b4..c3bd902 100644 --- a/examples/next/app/page.tsx +++ b/examples/next/app/page.tsx @@ -1,6 +1,7 @@ 'use client' import { createApi, type DevupObject } from '@devup-api/fetch' +import { getUserById } from '@devup-api/fetch/server' import { createQueryClient } from '@devup-api/react-query' import { ApiCrud } from '@devup-api/ui' import { schemas } from '@devup-api/zod' @@ -113,6 +114,18 @@ export default function Home() { + { + getUserById({ + params: { id: 1 }, + query: { name: 'John Doe' }, + }).then((res) => { + console.log(res) + }) + }} + > + hello + {(() => { diff --git a/examples/next/next.config.ts b/examples/next/next.config.ts index 9e694da..346e5bc 100644 --- a/examples/next/next.config.ts +++ b/examples/next/next.config.ts @@ -7,6 +7,9 @@ const config = devupApi( }, { openapiFiles: ['./openapi.json', './openapi2.json', './openapi3.json'], + serverActions: { + baseUrl: 'https://api.example.com', + }, }, ) diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts index fc59687..15f6cc2 100644 --- a/packages/core/src/options.ts +++ b/packages/core/src/options.ts @@ -28,4 +28,16 @@ export interface DevupApiOptions extends DevupApiTypeGeneratorOptions { * @default {'openapi.json'} */ openapiFiles?: string[] | string + + /** + * Generate Server Action wrappers for operationId-based API calls. + * + * @default {true} + */ + serverActions?: + | boolean + | { + enabled?: boolean + baseUrl?: string + } } diff --git a/packages/fetch/package.json b/packages/fetch/package.json index b165883..261be0a 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -8,13 +8,18 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js", + "require": "./dist/server.cjs" } }, "files": [ "dist" ], "scripts": { - "build": "tsc && bun build --target node --outfile=dist/index.js src/index.ts --production --packages=external && bun build --target node --outfile=dist/index.cjs --format=cjs src/index.ts --production --packages=external" + "build": "tsc && bun -e \"await Bun.write('dist/server.d.ts', await Bun.file('src/server.d.ts').text())\" && bun build --target node --outfile=dist/index.js src/index.ts --production --packages=external && bun build --target node --outfile=dist/index.cjs --format=cjs src/index.ts --production --packages=external && bun build --target node --outfile=dist/server.js src/server.ts --production --packages=external && bun build --target node --outfile=dist/server.cjs --format=cjs src/server.ts --production --packages=external" }, "publishConfig": { "access": "public" diff --git a/packages/fetch/src/__tests__/index.test.ts b/packages/fetch/src/__tests__/index.test.ts index 3113f99..71c26e1 100644 --- a/packages/fetch/src/__tests__/index.test.ts +++ b/packages/fetch/src/__tests__/index.test.ts @@ -7,5 +7,6 @@ test('index.ts exports', () => { expect({ ...indexModule }).toEqual({ DevupApi: expect.any(Function), createApi: expect.any(Function), + serializeApiResponse: expect.any(Function), }) }) diff --git a/packages/fetch/src/__tests__/server.test.ts b/packages/fetch/src/__tests__/server.test.ts new file mode 100644 index 0000000..5f5ca04 --- /dev/null +++ b/packages/fetch/src/__tests__/server.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test' +import type { DevupApiResponse } from '../api' +import { serializeApiResponse } from '../server-utils' + +describe('serializeApiResponse', () => { + test('converts successful responses to Server Action-safe plain objects', () => { + const response = new Response(JSON.stringify({ id: 1 }), { + status: 201, + statusText: 'Created', + headers: { 'X-Test': 'yes' }, + }) + const result: DevupApiResponse<{ id: number }, { message: string }> = { + data: { id: 1 }, + isOk: true, + isError: false, + response, + } + + expect(serializeApiResponse(result)).toEqual({ + data: { id: 1 }, + isOk: true, + isError: false, + response: { + headers: { + 'content-type': 'text/plain;charset=UTF-8', + 'x-test': 'yes', + }, + redirected: false, + status: 201, + statusText: 'Created', + type: response.type, + url: '', + }, + }) + }) + + test('converts error responses to Server Action-safe plain objects', () => { + const response = new Response('missing', { status: 404 }) + const result: DevupApiResponse<{ id: number }, { message: string }> = { + error: { message: 'Not found' }, + isOk: false, + isError: true, + response, + } + + expect(serializeApiResponse(result)).toEqual({ + error: { message: 'Not found' }, + isOk: false, + isError: true, + response: { + headers: { 'content-type': 'text/plain;charset=UTF-8' }, + redirected: false, + status: 404, + statusText: '', + type: response.type, + url: '', + }, + }) + }) +}) diff --git a/packages/fetch/src/__tests__/types.test.ts b/packages/fetch/src/__tests__/types.test.ts index c9d7403..bb98501 100644 --- a/packages/fetch/src/__tests__/types.test.ts +++ b/packages/fetch/src/__tests__/types.test.ts @@ -129,6 +129,26 @@ describe('DevupApiResponse', () => { }>() expectTypeOf().toEqualTypeOf<{ message: string; code: number }>() }) + + test('response metadata type can be customized', () => { + type SerializableResponse = { + status: number + headers: Record + } + type Response = DevupApiResponse< + { id: number }, + { message: string }, + SerializableResponse + > + + type SuccessCase = Extract + type ErrorCase = Extract + + expectTypeOf< + SuccessCase['response'] + >().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) }) // ============================================================================= diff --git a/packages/fetch/src/api.ts b/packages/fetch/src/api.ts index d0c3df8..6533e89 100644 --- a/packages/fetch/src/api.ts +++ b/packages/fetch/src/api.ts @@ -29,20 +29,20 @@ import { } from './utils' // biome-ignore lint/suspicious/noExplicitAny: any is used to allow for flexibility in the type -export type DevupApiResponse = +export type DevupApiResponse = | { data: T error?: undefined isOk: true isError: false - response: Response + response: TResponse } | { data?: undefined error: E isOk: false isError: true - response: Response + response: TResponse } export class DevupApi> { diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts index 1802b29..b0aef10 100644 --- a/packages/fetch/src/index.ts +++ b/packages/fetch/src/index.ts @@ -1,3 +1,4 @@ export * from '@devup-api/core' export * from './api' export { createApi } from './create-api' +export * from './server-utils' diff --git a/packages/fetch/src/server-utils.ts b/packages/fetch/src/server-utils.ts new file mode 100644 index 0000000..1f380be --- /dev/null +++ b/packages/fetch/src/server-utils.ts @@ -0,0 +1,52 @@ +import type { DevupApiResponse } from './api' + +export type SerializedResponse = { + headers: Record + redirected: boolean + status: number + statusText: string + type: string + url: string +} + +export type SerializedDevupApiResponse = DevupApiResponse< + T, + E, + SerializedResponse +> + +function serializeResponse(response: Response): SerializedResponse { + return { + headers: Object.fromEntries( + [...response.headers.entries()].map(([key, value]) => [ + key.toLowerCase(), + value, + ]), + ), + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + } +} + +export function serializeApiResponse( + result: DevupApiResponse, +): DevupApiResponse { + if (result.isOk) { + return { + data: result.data, + isOk: true, + isError: false, + response: serializeResponse(result.response), + } + } + + return { + error: result.error, + isOk: false, + isError: true, + response: serializeResponse(result.response), + } +} diff --git a/packages/fetch/src/server.d.ts b/packages/fetch/src/server.d.ts new file mode 100644 index 0000000..6276642 --- /dev/null +++ b/packages/fetch/src/server.d.ts @@ -0,0 +1,7 @@ +export type ColdServerAction = (...options: unknown[]) => Promise + +export declare const actions: Record + +declare module '@devup-api/fetch/server-generated' {} + +export * from '@devup-api/fetch/server-generated' diff --git a/packages/fetch/src/server.ts b/packages/fetch/src/server.ts new file mode 100644 index 0000000..fa47391 --- /dev/null +++ b/packages/fetch/src/server.ts @@ -0,0 +1,16 @@ +export type ColdServerAction = (...options: unknown[]) => Promise + +function createColdServerAction(name: string): ColdServerAction { + return async () => { + throw new Error( + `@devup-api/fetch/server.${name} is a cold-typing placeholder. Configure a devup-api bundler plugin so this import is replaced by generated Server Actions.`, + ) + } +} + +export const actions: Record = new Proxy( + {}, + { + get: (_target, property) => createColdServerAction(String(property)), + }, +) diff --git a/packages/generator/src/__tests__/generate-server-actions.test.ts b/packages/generator/src/__tests__/generate-server-actions.test.ts new file mode 100644 index 0000000..2d8b6e1 --- /dev/null +++ b/packages/generator/src/__tests__/generate-server-actions.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from 'bun:test' +import type { OpenAPIV3_1 } from 'openapi-types' +import { + generateServerActionCode, + generateServerActionTypes, +} from '../generate-server-actions' + +const createDocument = ( + doc: Partial = {}, +): OpenAPIV3_1.Document => + ({ + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: {}, + ...doc, + }) as OpenAPIV3_1.Document + +describe('generateServerActionCode', () => { + test('generates top-level server action functions for operationIds by default', () => { + const result = generateServerActionCode( + { + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { '200': { description: 'Success' } }, + }, + post: { + operationId: 'createUser', + responses: { '201': { description: 'Created' } }, + }, + }, + }, + }), + }, + { serverActions: { baseUrl: 'https://api.example.com' } }, + ) + + expect(result).toContain("'use server'") + expect(result).toContain("import { createApi } from '@devup-api/fetch'") + expect(result).toContain( + "import { serializeApiResponse } from '@devup-api/fetch'", + ) + expect(result).toContain("baseUrl: 'https://api.example.com'") + expect(result).toContain('export async function getUsers(') + expect(result).toContain( + "return serializeApiResponse(await api.get('getUsers'", + ) + expect(result).toContain('export async function createUser(') + expect(result).toContain( + "return serializeApiResponse(await api.post('createUser'", + ) + }) + + test('generates actions when serverActions is omitted', () => { + const result = generateServerActionCode({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }), + }) + + expect(result).toContain("'use server'") + expect(result).toContain('export async function getUsers') + }) + + test('does not generate actions when serverActions is explicitly disabled', () => { + const result = generateServerActionCode( + { + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }), + }, + { serverActions: false }, + ) + + expect(result).toContain("'use server'") + expect(result).not.toContain('export async function getUsers') + }) + + test('does not generate actions when serverActions object is disabled', () => { + const result = generateServerActionCode( + { + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }), + }, + { serverActions: { enabled: false } }, + ) + + expect(result).toContain("'use server'") + expect(result).not.toContain('export async function getUsers') + }) + + test('generates all operationIds with empty serverActions config', () => { + const result = generateServerActionCode( + { + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { '200': { description: 'Success' } }, + }, + post: { + operationId: 'createUser', + responses: { '201': { description: 'Created' } }, + }, + }, + '/users/{id}': { + delete: { + operationId: 'deleteUser', + responses: { '204': { description: 'Deleted' } }, + }, + }, + }, + }), + }, + { + serverActions: {}, + }, + ) + + expect(result).toContain('export async function getUsers') + expect(result).toContain('export async function createUser') + expect(result).toContain('export async function deleteUser') + }) + + test('uses serverName when generating actions for non-default schemas', () => { + const result = generateServerActionCode( + { + 'admin.json': createDocument({ + paths: { + '/admins': { + get: { + operationId: 'getAdmins', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }), + }, + undefined, + ) + + expect(result).toContain("serverName: 'admin.json'") + expect(result).toContain( + "return serializeApiResponse(await api.get('getAdmins'", + ) + }) + + test('creates safe action names for invalid and duplicate operationIds', () => { + const result = generateServerActionCode({ + 'openapi.json': createDocument({ + paths: { + '/numeric': { + get: { + operationId: '123NumericUser', + responses: { '200': { description: 'Success' } }, + }, + }, + '/shared': { + get: { + operationId: 'getSharedUser', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }), + 'admin-api.json': createDocument({ + paths: { + '/shared': { + get: { + operationId: 'getSharedUser', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('export async function _123numericUser(') + expect(result).toContain('export async function getSharedUser(') + expect(result).toContain( + 'export async function adminApiJson_getSharedUser(', + ) + }) +}) + +describe('generateServerActionTypes', () => { + test('declares the generated server action module', () => { + const result = generateServerActionTypes( + { + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }), + }, + undefined, + ) + + expect(result).toContain( + "declare module '@devup-api/fetch/server-generated'", + ) + expect(result).toContain('export function getUsers(') + expect(result).toContain('Promise { extractPathParams: expect.any(Function), generateCrudConfigCode: expect.any(Function), generateCrudConfigTypes: expect.any(Function), + // Server Action generation + generateServerActionCode: expect.any(Function), + generateServerActionTypes: expect.any(Function), parseCrudConfigs: expect.any(Function), parseCrudConfigsFromMultiple: expect.any(Function), parseDevupOperations: expect.any(Function), diff --git a/packages/generator/src/generate-server-actions.ts b/packages/generator/src/generate-server-actions.ts new file mode 100644 index 0000000..7b57ccd --- /dev/null +++ b/packages/generator/src/generate-server-actions.ts @@ -0,0 +1,194 @@ +import type { DevupApiOptions } from '@devup-api/core' +import type { OpenAPIV3_1 } from 'openapi-types' +import { convertCase } from './convert-case' + +type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' + +type ServerActionOperation = { + actionName: string + method: HttpMethod + operationId: string + serverName: string + apiVariableName: string +} + +function isServerActionsEnabled(options?: DevupApiOptions): boolean { + const serverActions = options?.serverActions + if (typeof serverActions === 'boolean') return serverActions + if (!serverActions) return true + return serverActions.enabled !== false +} + +function getServerActionsBaseUrl(options?: DevupApiOptions): string { + const serverActions = options?.serverActions + if (typeof serverActions === 'object') return serverActions.baseUrl ?? '' + return '' +} + +function quote(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` +} + +function toIdentifier(value: string): string { + const identifier = value.replace(/[^a-zA-Z0-9_$]/g, '_') + return /^[a-zA-Z_$]/.test(identifier) ? identifier : `_${identifier}` +} + +function toApiVariableName(serverName: string, index: number): string { + if (index === 0) return 'api' + return toIdentifier(`${convertCase(serverName, 'camel')}Api`) +} + +function getMethodScopeType(method: HttpMethod): string { + const pascal = `${method[0]?.toUpperCase()}${method.slice(1)}` + return `Devup${pascal}ApiStructScope` +} + +function collectOperations( + schemas: Record, + options?: DevupApiOptions, +): ServerActionOperation[] { + const convertCaseType = options?.convertCase ?? 'camel' + const operations: ServerActionOperation[] = [] + const usedActionNames = new Set() + let serverIndex = 0 + + for (const [serverName, schema] of Object.entries(schemas)) { + const apiVariableName = toApiVariableName(serverName, serverIndex) + serverIndex += 1 + + for (const pathItem of Object.values(schema.paths ?? {})) { + if (!pathItem) continue + for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { + const operation = pathItem[method] + if (!operation?.operationId) continue + + const rawActionName = toIdentifier( + convertCase(operation.operationId, convertCaseType), + ) + const actionName = usedActionNames.has(rawActionName) + ? toIdentifier(`${convertCase(serverName, 'camel')}_${rawActionName}`) + : rawActionName + usedActionNames.add(actionName) + + operations.push({ + actionName, + method, + operationId: rawActionName, + serverName, + apiVariableName, + }) + } + } + } + + return operations +} + +function generateApiDeclarations( + operations: ServerActionOperation[], + options?: DevupApiOptions, +): string { + const baseUrl = getServerActionsBaseUrl(options) + const serverNames = [ + ...new Set(operations.map((operation) => operation.serverName)), + ] + + return serverNames + .map((serverName, index) => { + const apiVariableName = toApiVariableName(serverName, index) + const serverNameProperty = + serverName === 'openapi.json' + ? '' + : `, serverName: ${quote(serverName)}` + return `const ${apiVariableName} = createApi({ baseUrl: ${quote(baseUrl)}${serverNameProperty} })` + }) + .join('\n') +} + +function generateAction(operation: ServerActionOperation): string { + const scopeType = `${operation.actionName}Scope` + const methodScopeType = getMethodScopeType(operation.method) + return `type ${scopeType} = Additional<${quote(operation.operationId)}, ${methodScopeType}<${quote(operation.serverName)}>> + +export async function ${operation.actionName}( + ...options: ApiOption<${scopeType}> +): Promise, ExtractValue<${scopeType}, 'error'>, SerializedResponse>> { + return serializeApiResponse(await ${operation.apiVariableName}.${operation.method}(${quote(operation.operationId)}, ...options)) +}` +} + +function generateActionDeclaration(operation: ServerActionOperation): string { + const scopeType = `${operation.actionName}Scope` + const methodScopeType = getMethodScopeType(operation.method) + return `type ${scopeType} = Additional<${quote(operation.operationId)}, ${methodScopeType}<${quote(operation.serverName)}>> + export function ${operation.actionName}( + ...options: ApiOption<${scopeType}> + ): Promise, ExtractValue<${scopeType}, 'error'>, SerializedResponse>>` +} + +export function generateServerActionCode( + schemas: Record, + options?: DevupApiOptions, +): string { + const operations = isServerActionsEnabled(options) + ? collectOperations(schemas, options) + : [] + const apiDeclarations = generateApiDeclarations(operations, options) + const actions = operations.map(generateAction).join('\n\n') + + return `// Auto-generated Server Actions from OpenAPI specs +// Do not edit this file directly +'use server' + +import type { + Additional, + ApiOption, + ExtractValue, + DevupDeleteApiStructScope, + DevupGetApiStructScope, + DevupPatchApiStructScope, + DevupPostApiStructScope, + DevupPutApiStructScope, + DevupApiResponse, + SerializedResponse, +} from '@devup-api/fetch' +import { createApi } from '@devup-api/fetch' +import { serializeApiResponse } from '@devup-api/fetch' + +${apiDeclarations} + +${actions} +` +} + +export function generateServerActionTypes( + schemas: Record, + options?: DevupApiOptions, +): string { + const operations = isServerActionsEnabled(options) + ? collectOperations(schemas, options) + : [] + const declarations = operations.map(generateActionDeclaration).join('\n\n') + + return `// Auto-generated Server Action types from OpenAPI specs +// Do not edit this file directly + +declare module '@devup-api/fetch/server-generated' { + import type { + Additional, + ApiOption, + ExtractValue, + DevupDeleteApiStructScope, + DevupGetApiStructScope, + DevupPatchApiStructScope, + DevupPostApiStructScope, + DevupPutApiStructScope, + DevupApiResponse, + SerializedResponse, + } from '@devup-api/fetch' + + ${declarations} +} +` +} diff --git a/packages/generator/src/index.ts b/packages/generator/src/index.ts index ef9a697..f02852b 100644 --- a/packages/generator/src/index.ts +++ b/packages/generator/src/index.ts @@ -2,6 +2,7 @@ export * from './create-url-map' export * from './crud-types' export * from './generate-crud-config' export * from './generate-interface' +export * from './generate-server-actions' export * from './generate-zod' export * from './openapi-utils' export * from './parse-crud-tags' diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index 687b939..78898aa 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -72,6 +72,16 @@ interface DevupApiOptions { * @default true */ responseDefaultNonNullable?: boolean + + /** + * Generate operationId-based Server Action wrappers. + * Disable with false or { enabled: false }. + * @default true + */ + serverActions?: boolean | { + enabled?: boolean + baseUrl?: string + } } ``` @@ -83,14 +93,16 @@ When using Turbopack (Next.js 13+), the plugin: 1. Reads your `openapi.json` file 2. Generates TypeScript interface definitions 3. Creates a URL map and sets it as `process.env.DEVUP_API_URL_MAP` -4. Makes types available for use with `@devup-api/fetch` +4. Generates Server Actions in `df/server.ts` by default +5. Makes types available for use with `@devup-api/fetch` ### Webpack Mode When using Webpack, the plugin uses `@devup-api/webpack-plugin` internally to: 1. Generate types during build time 2. Inject URL map via webpack DefinePlugin -3. Make types available for use with `@devup-api/fetch` +3. Generate Server Actions in `df/server.ts` by default +4. Make types available for use with `@devup-api/fetch` ## TypeScript Configuration diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 6fd132d..a62c23e 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -5,6 +5,8 @@ import { generateCrudConfigCode, generateCrudConfigTypes, generateInterface, + generateServerActionCode, + generateServerActionTypes, generateZodSchemas, generateZodTypeDeclarations, } from '@devup-api/generator' @@ -47,6 +49,8 @@ export function devupApi( generateZodTypeDeclarations, generateCrudConfigCode, generateCrudConfigTypes, + generateServerActionCode, + generateServerActionTypes, createUrlMap, } @@ -69,6 +73,7 @@ export function devupApi( '@devup-api/ui/crud': toRelativePath( resolve(tempDir, 'crud-configs.jsx'), ), + '@devup-api/fetch/server': toRelativePath(resolve(tempDir, 'server.ts')), }) return config diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 7c184e3..5c4be53 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -73,6 +73,16 @@ interface DevupApiOptions { * @default true */ responseDefaultNonNullable?: boolean + + /** + * Generate operationId-based Server Action wrappers. + * Disable with false or { enabled: false }. + * @default true + */ + serverActions?: boolean | { + enabled?: boolean + baseUrl?: string + } } ``` @@ -81,7 +91,8 @@ interface DevupApiOptions { 1. Reads your `openapi.json` file during build 2. Generates TypeScript interface definitions (`api.d.ts`) 3. Creates a URL map and injects it as `process.env.DEVUP_API_URL_MAP` via Rsbuild's define feature -4. Makes types available for use with `@devup-api/fetch` +4. Generates Server Actions in `df/server.ts` by default +5. Makes types available for use with `@devup-api/fetch` ## TypeScript Configuration diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index cca7f2e..28266d2 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -130,8 +130,8 @@ test.each([ mockSchema, options, ) - // 5 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts - expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(5) + // 7 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts, server.ts, server-module.d.ts + expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(7) expect(mockWriteInterfaceAsync).toHaveBeenCalledWith( join('df', 'api.d.ts'), mockInterfaceContent, @@ -165,6 +165,7 @@ test('devupApiRsbuildPlugin setup hook modifies config with urlMap and alias', a alias: { '@devup-api/zod': resolve('df', 'zod-schemas.js'), '@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'), + '@devup-api/fetch/server': resolve('df', 'server.ts'), }, }, source: { @@ -192,6 +193,7 @@ test('devupApiRsbuildPlugin setup hook handles config without source', async () alias: { '@devup-api/zod': resolve('df', 'zod-schemas.js'), '@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'), + '@devup-api/fetch/server': resolve('df', 'server.ts'), }, }, source: { @@ -221,6 +223,7 @@ test('devupApiRsbuildPlugin setup hook handles config without define', async () alias: { '@devup-api/zod': resolve('df', 'zod-schemas.js'), '@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'), + '@devup-api/fetch/server': resolve('df', 'server.ts'), }, }, source: { @@ -251,6 +254,7 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is null', alias: { '@devup-api/zod': resolve('df', 'zod-schemas.js'), '@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'), + '@devup-api/fetch/server': resolve('df', 'server.ts'), }, }, source: { @@ -277,6 +281,7 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is undefi alias: { '@devup-api/zod': resolve('df', 'zod-schemas.js'), '@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'), + '@devup-api/fetch/server': resolve('df', 'server.ts'), }, }, source: { @@ -303,6 +308,7 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is empty alias: { '@devup-api/zod': resolve('df', 'zod-schemas.js'), '@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'), + '@devup-api/fetch/server': resolve('df', 'server.ts'), }, }, source: { diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 19ceaad..a216928 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -5,6 +5,8 @@ import { generateCrudConfigCode, generateCrudConfigTypes, generateInterface, + generateServerActionCode, + generateServerActionTypes, generateZodSchemas, generateZodTypeDeclarations, } from '@devup-api/generator' @@ -38,6 +40,8 @@ export function devupApiRsbuildPlugin( generateZodTypeDeclarations, generateCrudConfigCode, generateCrudConfigTypes, + generateServerActionCode, + generateServerActionTypes, createUrlMap, } @@ -50,6 +54,7 @@ export function devupApiRsbuildPlugin( // Get absolute paths for virtual modules const zodSchemasPath = resolve(tempDir, 'zod-schemas.js') const crudConfigsPath = resolve(tempDir, 'crud-configs.jsx') + const serverPath = resolve(tempDir, 'server.ts') build.modifyRsbuildConfig((config) => { config.source ??= {} @@ -70,6 +75,9 @@ export function devupApiRsbuildPlugin( ;(config.resolve.alias as Record)[ '@devup-api/ui/crud' ] = crudConfigsPath + ;(config.resolve.alias as Record)[ + '@devup-api/fetch/server' + ] = serverPath return config }) diff --git a/packages/utils/src/__tests__/generate-devup.test.ts b/packages/utils/src/__tests__/generate-devup.test.ts index b3dbe4d..e3223d6 100644 --- a/packages/utils/src/__tests__/generate-devup.test.ts +++ b/packages/utils/src/__tests__/generate-devup.test.ts @@ -47,6 +47,8 @@ function createMockGenerators(): DevupGenerators<{ generateZodTypeDeclarations: mock(() => 'zod-types-content'), generateCrudConfigCode: mock(() => 'crud-config-content'), generateCrudConfigTypes: mock(() => 'crud-types-content'), + generateServerActionCode: mock(() => 'server-actions-content'), + generateServerActionTypes: mock(() => 'server-actions-types-content'), createUrlMap: mock(() => mockUrlMap), } } @@ -86,6 +88,8 @@ test('generateDevupArtifactsAsync returns correct artifacts', async () => { expect(result.files.zodTypes).toBe('zod-types-content') expect(result.files.crudConfig).toBe('crud-config-content') expect(result.files.crudTypes).toBe('crud-types-content') + expect(result.files.serverActions).toBe('server-actions-content') + expect(result.files.serverActionTypes).toBe('server-actions-types-content') expect(result.urlMap).toBe(mockUrlMap) }) @@ -122,16 +126,24 @@ test('generateDevupArtifactsAsync calls all generators with schemas and options' ) expect(generators.generateCrudConfigCode).toHaveBeenCalledWith(mockSchemas) expect(generators.generateCrudConfigTypes).toHaveBeenCalledWith(mockSchemas) + expect(generators.generateServerActionCode).toHaveBeenCalledWith( + mockSchemas, + options, + ) + expect(generators.generateServerActionTypes).toHaveBeenCalledWith( + mockSchemas, + options, + ) expect(generators.createUrlMap).toHaveBeenCalledWith(mockSchemas, options) }) -test('generateDevupArtifactsAsync writes all 5 files to tempDir', async () => { +test('generateDevupArtifactsAsync writes all 7 files to tempDir', async () => { const io = createMockIOAsync() const generators = createMockGenerators() await generateDevupArtifactsAsync(io, generators) - expect(io.writeInterfaceAsync).toHaveBeenCalledTimes(5) + expect(io.writeInterfaceAsync).toHaveBeenCalledTimes(7) expect(io.writeInterfaceAsync).toHaveBeenCalledWith( join('df', 'api.d.ts'), 'interface-content', @@ -152,6 +164,14 @@ test('generateDevupArtifactsAsync writes all 5 files to tempDir', async () => { join('df', 'ui.d.ts'), 'crud-types-content', ) + expect(io.writeInterfaceAsync).toHaveBeenCalledWith( + join('df', 'server.ts'), + 'server-actions-content', + ) + expect(io.writeInterfaceAsync).toHaveBeenCalledWith( + join('df', 'server-module.d.ts'), + 'server-actions-types-content', + ) }) test('generateDevupArtifactsAsync passes undefined options when none provided', async () => { @@ -186,6 +206,8 @@ test('generateDevupArtifacts returns correct artifacts', () => { expect(result.files.zodTypes).toBe('zod-types-content') expect(result.files.crudConfig).toBe('crud-config-content') expect(result.files.crudTypes).toBe('crud-types-content') + expect(result.files.serverActions).toBe('server-actions-content') + expect(result.files.serverActionTypes).toBe('server-actions-types-content') expect(result.urlMap).toBe(mockUrlMap) }) @@ -222,16 +244,24 @@ test('generateDevupArtifacts calls all generators with schemas and options', () ) expect(generators.generateCrudConfigCode).toHaveBeenCalledWith(mockSchemas) expect(generators.generateCrudConfigTypes).toHaveBeenCalledWith(mockSchemas) + expect(generators.generateServerActionCode).toHaveBeenCalledWith( + mockSchemas, + options, + ) + expect(generators.generateServerActionTypes).toHaveBeenCalledWith( + mockSchemas, + options, + ) expect(generators.createUrlMap).toHaveBeenCalledWith(mockSchemas, options) }) -test('generateDevupArtifacts writes all 5 files to tempDir', () => { +test('generateDevupArtifacts writes all 7 files to tempDir', () => { const io = createMockIOSync() const generators = createMockGenerators() generateDevupArtifacts(io, generators) - expect(io.writeInterface).toHaveBeenCalledTimes(5) + expect(io.writeInterface).toHaveBeenCalledTimes(7) expect(io.writeInterface).toHaveBeenCalledWith( join('df', 'api.d.ts'), 'interface-content', @@ -252,6 +282,14 @@ test('generateDevupArtifacts writes all 5 files to tempDir', () => { join('df', 'ui.d.ts'), 'crud-types-content', ) + expect(io.writeInterface).toHaveBeenCalledWith( + join('df', 'server.ts'), + 'server-actions-content', + ) + expect(io.writeInterface).toHaveBeenCalledWith( + join('df', 'server-module.d.ts'), + 'server-actions-types-content', + ) }) test('generateDevupArtifacts passes undefined options when none provided', () => { diff --git a/packages/utils/src/generate-devup.ts b/packages/utils/src/generate-devup.ts index bb14a97..267a65c 100644 --- a/packages/utils/src/generate-devup.ts +++ b/packages/utils/src/generate-devup.ts @@ -33,6 +33,14 @@ export interface DevupGenerators { generateCrudConfigTypes: ( schemas: Record, ) => string + generateServerActionCode: ( + schemas: Record, + options?: TOptions, + ) => string + generateServerActionTypes: ( + schemas: Record, + options?: TOptions, + ) => string createUrlMap: ( schemas: Record, options?: TOptions, @@ -70,6 +78,8 @@ export interface DevupGeneratedFiles { zodTypes: string crudConfig: string crudTypes: string + serverActions: string + serverActionTypes: string } /** @@ -103,6 +113,8 @@ export async function generateDevupArtifactsAsync< zodTypes: generators.generateZodTypeDeclarations(schemas, options), crudConfig: generators.generateCrudConfigCode(schemas), crudTypes: generators.generateCrudConfigTypes(schemas), + serverActions: generators.generateServerActionCode(schemas, options), + serverActionTypes: generators.generateServerActionTypes(schemas, options), } await Promise.all([ @@ -111,6 +123,11 @@ export async function generateDevupArtifactsAsync< io.writeInterfaceAsync(join(tempDir, 'zod.d.ts'), files.zodTypes), io.writeInterfaceAsync(join(tempDir, 'crud-configs.jsx'), files.crudConfig), io.writeInterfaceAsync(join(tempDir, 'ui.d.ts'), files.crudTypes), + io.writeInterfaceAsync(join(tempDir, 'server.ts'), files.serverActions), + io.writeInterfaceAsync( + join(tempDir, 'server-module.d.ts'), + files.serverActionTypes, + ), ]) const urlMap = generators.createUrlMap(schemas, options) @@ -136,6 +153,8 @@ export function generateDevupArtifacts( zodTypes: generators.generateZodTypeDeclarations(schemas, options), crudConfig: generators.generateCrudConfigCode(schemas), crudTypes: generators.generateCrudConfigTypes(schemas), + serverActions: generators.generateServerActionCode(schemas, options), + serverActionTypes: generators.generateServerActionTypes(schemas, options), } io.writeInterface(join(tempDir, 'api.d.ts'), files.interface) @@ -143,6 +162,11 @@ export function generateDevupArtifacts( io.writeInterface(join(tempDir, 'zod.d.ts'), files.zodTypes) io.writeInterface(join(tempDir, 'crud-configs.jsx'), files.crudConfig) io.writeInterface(join(tempDir, 'ui.d.ts'), files.crudTypes) + io.writeInterface(join(tempDir, 'server.ts'), files.serverActions) + io.writeInterface( + join(tempDir, 'server-module.d.ts'), + files.serverActionTypes, + ) const urlMap = generators.createUrlMap(schemas, options) return { tempDir, schemas, files, urlMap } diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 7a75877..0447720 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -73,6 +73,16 @@ interface DevupApiOptions { * @default true */ responseDefaultNonNullable?: boolean + + /** + * Generate operationId-based Server Action wrappers. + * Disable with false or { enabled: false }. + * @default true + */ + serverActions?: boolean | { + enabled?: boolean + baseUrl?: string + } } ``` @@ -81,7 +91,8 @@ interface DevupApiOptions { 1. Reads your `openapi.json` file during build 2. Generates TypeScript interface definitions (`api.d.ts`) 3. Creates a URL map and injects it as `process.env.DEVUP_API_URL_MAP` -4. Makes types available for use with `@devup-api/fetch` +4. Generates Server Actions in `df/server.ts` by default +5. Makes types available for use with `@devup-api/fetch` ## TypeScript Configuration diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 97bbdab..0d4cbbd 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -14,6 +14,7 @@ let mockGenerateZodSchemas: ReturnType let mockGenerateZodTypeDeclarations: ReturnType let mockGenerateCrudConfigCode: ReturnType let mockGenerateCrudConfigTypes: ReturnType +let mockGenerateServerActionCode: ReturnType const mockSchema = { openapi: '3.1.0', @@ -53,6 +54,7 @@ const mockZodSchemasContent = 'export const schemas = {}' const mockZodTypeDeclarationsContent = 'declare module "@devup-api/zod" {}' const mockCrudConfigCodeContent = 'export function UserCrud() {}' const mockCrudConfigTypesContent = 'declare module "@devup-api/ui/crud" {}' +const mockServerActionsContent = "'use server'" beforeEach(() => { mockCreateTmpDirAsync = spyOn(utils, 'createTmpDirAsync').mockResolvedValue( @@ -87,6 +89,10 @@ beforeEach(() => { generator, 'generateCrudConfigTypes', ).mockReturnValue(mockCrudConfigTypesContent) + mockGenerateServerActionCode = spyOn( + generator, + 'generateServerActionCode', + ).mockReturnValue(mockServerActionsContent) }) test('devupApi returns plugin with correct name', () => { @@ -294,6 +300,34 @@ test('devupApi resolveId returns null for partial ui module path', () => { expect(resolveId('@devup-api/ui/other')).toBeNull() }) +// ============================================================================= +// Virtual Server Module Tests +// ============================================================================= + +test('devupApi resolveId returns resolved virtual module for @devup-api/fetch/server', () => { + const plugin = devupApi() + const resolveId = plugin.resolveId as (id: string) => string | null + + const result = resolveId('@devup-api/fetch/server') + expect(result).toBe('\0@devup-api/fetch/server') +}) + +test('devupApi load returns server action code for virtual server module', async () => { + const plugin = devupApi() + const load = plugin.load as (id: string) => Promise + + const result = await load('\0@devup-api/fetch/server') + expect(result).toBe(mockServerActionsContent) + expect(mockGenerateServerActionCode).toHaveBeenCalled() +}) + +test('devupApi load returns null for non-resolved server module', async () => { + const plugin = devupApi() + const load = plugin.load as (id: string) => Promise + + expect(await load('@devup-api/fetch/server')).toBeNull() +}) + test('devupApi load returns crud config code for virtual ui module', async () => { const plugin = devupApi() const load = plugin.load as (id: string) => Promise diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index dd48000..ff98154 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -4,6 +4,8 @@ import { generateCrudConfigCode, generateCrudConfigTypes, generateInterface, + generateServerActionCode, + generateServerActionTypes, generateZodSchemas, generateZodTypeDeclarations, } from '@devup-api/generator' @@ -25,6 +27,9 @@ const RESOLVED_VIRTUAL_ZOD_MODULE = `\0${VIRTUAL_ZOD_MODULE}` const VIRTUAL_UI_MODULE = '@devup-api/ui/crud' const RESOLVED_VIRTUAL_UI_MODULE = `\0${VIRTUAL_UI_MODULE}` +const VIRTUAL_SERVER_MODULE = '@devup-api/fetch/server' +const RESOLVED_VIRTUAL_SERVER_MODULE = `\0${VIRTUAL_SERVER_MODULE}` + export function devupApi(options?: DevupApiOptions): Plugin { let artifacts: DevupArtifacts | null = null @@ -41,6 +46,8 @@ export function devupApi(options?: DevupApiOptions): Plugin { generateZodTypeDeclarations, generateCrudConfigCode, generateCrudConfigTypes, + generateServerActionCode, + generateServerActionTypes, createUrlMap, } @@ -62,6 +69,9 @@ export function devupApi(options?: DevupApiOptions): Plugin { if (id === VIRTUAL_UI_MODULE) { return RESOLVED_VIRTUAL_UI_MODULE } + if (id === VIRTUAL_SERVER_MODULE) { + return RESOLVED_VIRTUAL_SERVER_MODULE + } return null }, @@ -75,6 +85,10 @@ export function devupApi(options?: DevupApiOptions): Plugin { const { files } = await getArtifacts() return files.crudConfig } + if (id === RESOLVED_VIRTUAL_SERVER_MODULE) { + const { files } = await getArtifacts() + return files.serverActions + } return null }, diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index e9fa691..01ee273 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -86,6 +86,16 @@ interface DevupApiOptions { * @default true */ responseDefaultNonNullable?: boolean + + /** + * Generate operationId-based Server Action wrappers. + * Disable with false or { enabled: false }. + * @default true + */ + serverActions?: boolean | { + enabled?: boolean + baseUrl?: string + } } ``` @@ -94,7 +104,8 @@ interface DevupApiOptions { 1. Reads your `openapi.json` file during build (before compilation) 2. Generates TypeScript interface definitions (`api.d.ts`) 3. Creates a URL map and injects it as `process.env.DEVUP_API_URL_MAP` via webpack DefinePlugin -4. Makes types available for use with `@devup-api/fetch` +4. Generates Server Actions in `df/server.ts` by default +5. Makes types available for use with `@devup-api/fetch` ## TypeScript Configuration diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index c9e6fba..f460bec 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -231,8 +231,8 @@ test.each([ join('df', 'zod.d.ts'), mockZodTypeDeclarationsContent, ) - // 5 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts - expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(5) + // 7 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts, server.ts, server-module.d.ts + expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(7) expect(mockCreateUrlMap).toHaveBeenCalledWith(mockSchema, options || {}) expect(definePluginApplySpy).toHaveBeenCalled() expect(normalModuleReplacementPluginApplySpy).toHaveBeenCalled() @@ -322,8 +322,8 @@ test('devupApiWebpackPlugin beforeCompile hook only runs once when called multip expect(mockGenerateInterface).toHaveBeenCalledTimes(1) expect(mockGenerateZodSchemas).toHaveBeenCalledTimes(1) expect(mockGenerateZodTypeDeclarations).toHaveBeenCalledTimes(1) - // 5 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts - expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(5) + // 7 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts, server.ts, server-module.d.ts + expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(7) expect(mockCreateUrlMap).toHaveBeenCalledTimes(1) expect(mockCallback1).toHaveBeenCalled() expect(mockCallback2).toHaveBeenCalled() diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index e812cea..bc68c87 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -5,6 +5,8 @@ import { generateCrudConfigCode, generateCrudConfigTypes, generateInterface, + generateServerActionCode, + generateServerActionTypes, generateZodSchemas, generateZodTypeDeclarations, } from '@devup-api/generator' @@ -54,6 +56,8 @@ export class devupApiWebpackPlugin { generateZodTypeDeclarations, generateCrudConfigCode, generateCrudConfigTypes, + generateServerActionCode, + generateServerActionTypes, createUrlMap, } @@ -90,6 +94,12 @@ export class devupApiWebpackPlugin { crudConfigPath, ).apply(compiler) + const serverPath = resolve(tempDir, 'server.ts') + new compiler.webpack.NormalModuleReplacementPlugin( + /^@devup-api\/fetch\/server$/, + serverPath, + ).apply(compiler) + callback() } catch (error) { this.initialized = false