diff --git a/.changeset/famous-coins-open.md b/.changeset/famous-coins-open.md new file mode 100644 index 0000000..0f75d12 --- /dev/null +++ b/.changeset/famous-coins-open.md @@ -0,0 +1,5 @@ +--- +"prisma-openapi": minor +--- + +Allow to exclude fields from prisma models diff --git a/.nvmrc b/.nvmrc index dc5620a..f94d3c2 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.15.0 \ No newline at end of file +24.13.1 \ No newline at end of file diff --git a/README.md b/README.md index 41ba271..e1333ae 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A Prisma generator that automatically creates OpenAPI specifications from your P - [Custom Configuration](#custom-configuration) - [JSDoc Integration](#jsdoc-integration) - [Prisma Comments as Descriptions](#prisma-comments-as-descriptions) + - [Field Exclusion](#field-exclusion) - [Configuration](#configuration) - [License](#license) @@ -281,6 +282,38 @@ User: description: Optional display name ``` +### Field Exclusion + +You can exclude specific fields from the generated OpenAPI schema using two approaches: + +**1. Using `@openapi.ignore` in field comments:** + +Add `@openapi.ignore` to a field's triple-slash comment to exclude it from the generated schema: + +```prisma +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + /// @openapi.ignore + password String +} +``` + +**2. Using `excludeFields` in generator config:** + +Specify fields to exclude using `ModelName.fieldName` format: + +```prisma +generator openapi { + provider = "prisma-openapi" + output = "./openapi" + excludeFields = "User.password, User.secretKey" +} +``` + +Both approaches can be used together. A field is excluded if it matches **either** condition. Excluded fields are removed from both `properties` and `required` in the generated schema. + ## Configuration | Option | Description | Default | @@ -290,6 +323,7 @@ User: | `description` | API description in OpenAPI spec | Empty string | | `includeModels` | Comma-separated list of models to include | All models | | `excludeModels` | Comma-separated list of models to exclude | None | +| `excludeFields` | Comma-separated list of fields to exclude (`ModelName.fieldName`) | None | | `generateYaml` | Generate YAML format | `true` | | `generateJson` | Generate JSON format | `false` | | `generateJsDoc` | Include JSDoc comments in the schema | `false` | diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 34d13cd..36a42bd 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,4 +1,4 @@ -import { build } from 'esbuild'; +import {build} from 'esbuild'; await build({ entryPoints: ['src/lib/index.ts'], diff --git a/package.json b/package.json index 383e3e9..bf0319c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "author": "Nitzan Ohana <16689354+nitzano@users.noreply.github.com>", "repository": "git@github.com:nitzano/prisma-openapi.git", "version": "1.5.6", - "description": "", + "description": "Generate OpenAPI documentation from prisma schema", "main": "dist/index.js", "bin": { "prisma-openapi": "./dist/index.js" diff --git a/src/on-generate/generate-js-doc-content.ts b/src/on-generate/generate-js-doc-content.ts index 1b97b7d..c538aa9 100644 --- a/src/on-generate/generate-js-doc-content.ts +++ b/src/on-generate/generate-js-doc-content.ts @@ -1,4 +1,5 @@ import type {GeneratorOptions} from '@prisma/generator-helper'; +import {isFieldIgnored} from './is-field-ignored.js'; /** * Generate JSDoc OpenAPI comments for Prisma models @@ -7,6 +8,7 @@ export function generateJsDocumentContent( models: GeneratorOptions['dmmf']['datamodel']['models'], filteredModels: GeneratorOptions['dmmf']['datamodel']['models'], enums: GeneratorOptions['dmmf']['datamodel']['enums'], + excludeFields?: string[], ): string { // Create JSDoc OpenAPI content with a single block let jsDocumentContent = `/** @@ -20,9 +22,9 @@ export function generateJsDocumentContent( * ${model.name}: * type: object * properties: -${generateModelProperties(model).trimEnd()} +${generateModelProperties(model, excludeFields).trimEnd()} * required: -${generateRequiredProperties(model)}`; +${generateRequiredProperties(model, excludeFields)}`; } // Add enum schemas @@ -46,10 +48,15 @@ ${generateEnumValues(enumType)}`; */ function generateModelProperties( model: GeneratorOptions['dmmf']['datamodel']['models'][0], + excludeFields?: string[], ): string { let properties = ''; for (const field of model.fields) { + if (isFieldIgnored(model.name, field, excludeFields)) { + continue; + } + let propertyType = ''; // Handle different field types @@ -165,9 +172,13 @@ function generateModelProperties( */ function generateRequiredProperties( model: GeneratorOptions['dmmf']['datamodel']['models'][0], + excludeFields?: string[], ): string { const requiredFields = model.fields - .filter((field) => field.isRequired) + .filter( + (field) => + field.isRequired && !isFieldIgnored(model.name, field, excludeFields), + ) .map((field) => field.name); return requiredFields.map((field) => ` * - ${field}`).join('\n'); diff --git a/src/on-generate/generate-open-api-spec.ts b/src/on-generate/generate-open-api-spec.ts index 9dae6f8..a346ee5 100644 --- a/src/on-generate/generate-open-api-spec.ts +++ b/src/on-generate/generate-open-api-spec.ts @@ -1,7 +1,11 @@ import type {GeneratorOptions} from '@prisma/generator-helper'; import {OpenApiBuilder, type SchemaObject} from 'openapi3-ts/oas31'; import {generatePropertiesFromModel} from './generate-properties-from-model.js'; -import {type PrismaOpenApiOptions} from './generator-options.js'; +import { + type PrismaOpenApiOptions, + parseCommaSeparatedList, +} from './generator-options.js'; +import {isFieldIgnored} from './is-field-ignored.js'; /** * Generate an OpenAPI specification object from Prisma models @@ -18,14 +22,25 @@ export function generateOpenApiSpec( version: '1.0.0', }); + const excludeFieldsList = parseCommaSeparatedList(options.excludeFields); + // Create schemas for all filtered models for (const model of filteredModels) { const modelSchema: SchemaObject = { type: 'object', description: model.documentation, - properties: generatePropertiesFromModel(model, filteredModels, enums), + properties: generatePropertiesFromModel( + model, + filteredModels, + enums, + excludeFieldsList, + ), required: model.fields - .filter((field) => field.isRequired) + .filter( + (field) => + field.isRequired && + !isFieldIgnored(model.name, field, excludeFieldsList), + ) .map((field) => field.name), }; builder.addSchema(model.name, modelSchema); diff --git a/src/on-generate/generate-properties-from-model.ts b/src/on-generate/generate-properties-from-model.ts index 3f02241..4a792a4 100644 --- a/src/on-generate/generate-properties-from-model.ts +++ b/src/on-generate/generate-properties-from-model.ts @@ -1,5 +1,50 @@ import type {GeneratorOptions} from '@prisma/generator-helper'; import {type ReferenceObject, type SchemaObject} from 'openapi3-ts/oas31'; +import {isFieldIgnored} from './is-field-ignored.js'; + +/** + * Map a Prisma scalar type to an OpenAPI schema object + */ +function mapScalarType(type: string): SchemaObject { + switch (type) { + case 'String': { + return {type: 'string'}; + } + + case 'Int': { + return {type: 'integer', format: 'int32'}; + } + + case 'BigInt': { + return {type: 'integer', format: 'int64'}; + } + + case 'Float': + case 'Decimal': { + return {type: 'number', format: 'double'}; + } + + case 'Boolean': { + return {type: 'boolean'}; + } + + case 'DateTime': { + return {type: 'string', format: 'date-time'}; + } + + case 'Json': { + return {type: 'object'}; + } + + case 'unsupported': { + return {type: 'string', description: 'Unsupported type'}; + } + + default: { + return {type: 'string', description: 'Unknown type'}; + } + } +} /** * Generate OpenAPI properties from a Prisma model @@ -8,70 +53,21 @@ export function generatePropertiesFromModel( model: GeneratorOptions['dmmf']['datamodel']['models'][0], allModels: GeneratorOptions['dmmf']['datamodel']['models'], enums: GeneratorOptions['dmmf']['datamodel']['enums'], + excludeFields?: string[], ): Record { const properties: Record = {}; for (const field of model.fields) { + if (isFieldIgnored(model.name, field, excludeFields)) { + continue; + } + let property: SchemaObject | ReferenceObject; // Handle different field types switch (field.kind) { case 'scalar': { - // Map Prisma scalar types to OpenAPI types - const scalarProperty: SchemaObject = {}; - switch (field.type) { - case 'String': { - scalarProperty.type = 'string'; - break; - } - - case 'Int': { - scalarProperty.type = 'integer'; - scalarProperty.format = 'int32'; - break; - } - - case 'BigInt': { - scalarProperty.type = 'integer'; - scalarProperty.format = 'int64'; - break; - } - - case 'Float': - case 'Decimal': { - scalarProperty.type = 'number'; - scalarProperty.format = 'double'; - break; - } - - case 'Boolean': { - scalarProperty.type = 'boolean'; - break; - } - - case 'DateTime': { - scalarProperty.type = 'string'; - scalarProperty.format = 'date-time'; - break; - } - - case 'Json': { - scalarProperty.type = 'object'; - break; - } - - case 'unsupported': { - scalarProperty.type = 'string'; - scalarProperty.description = 'Unsupported type'; - break; - } - - default: { - scalarProperty.type = 'string'; - scalarProperty.description = 'Unknown type'; - break; - } - } + const scalarProperty = mapScalarType(field.type); if (field.isList) { property = { diff --git a/src/on-generate/generator-options.ts b/src/on-generate/generator-options.ts index 3207608..bf99775 100644 --- a/src/on-generate/generator-options.ts +++ b/src/on-generate/generator-options.ts @@ -32,6 +32,12 @@ export type PrismaOpenApiOptions = { */ excludeModels?: string; + /** + * Comma-separated list of fields to exclude (format: "ModelName.fieldName") + * @default undefined (none) + */ + excludeFields?: string; + /** * Generate YAML format * @default true diff --git a/src/on-generate/is-field-ignored.ts b/src/on-generate/is-field-ignored.ts new file mode 100644 index 0000000..b1b0f8d --- /dev/null +++ b/src/on-generate/is-field-ignored.ts @@ -0,0 +1,30 @@ +const openapiIgnoreTag = '@openapi.ignore'; + +/** + * Check if a field should be ignored in OpenAPI generation. + * A field is ignored if: + * 1. Its documentation contains @openapi.ignore + * 2. It is listed in the excludeFields option (as "ModelName.fieldName") + */ +export function isFieldIgnored( + modelName: string, + field: {name: string; documentation?: string | undefined}, + excludeFields?: string[], +): boolean { + if (field.documentation?.includes(openapiIgnoreTag)) { + return true; + } + + if (excludeFields?.includes(`${modelName}.${field.name}`)) { + return true; + } + + return false; +} + +/** + * Strip the @openapi.ignore tag from documentation so it doesn't leak into descriptions + */ +export function cleanDocumentation(documentation: string): string { + return documentation.replace(openapiIgnoreTag, '').trim(); +} diff --git a/src/on-generate/on-generate.ts b/src/on-generate/on-generate.ts index f45a446..0ea21a3 100644 --- a/src/on-generate/on-generate.ts +++ b/src/on-generate/on-generate.ts @@ -92,10 +92,14 @@ export async function onGenerate(options: GeneratorOptions) { // Write JSDoc file if enabled if (prismaOpenApiOptions.generateJsDoc) { const jsDocumentPath = path.join(outputDirectory, 'openapi.js'); + const excludeFieldsList = parseCommaSeparatedList( + prismaOpenApiOptions.excludeFields, + ); const jsDocumentContent = generateJsDocumentContent( dmmf.datamodel.models, filteredModels, dmmf.datamodel.enums, + excludeFieldsList, ); fs.writeFileSync(jsDocumentPath, jsDocumentContent); logger.info(`OpenAPI JSDoc specification written to ${jsDocumentPath}`); diff --git a/tests/exclude-fields.test.ts b/tests/exclude-fields.test.ts new file mode 100644 index 0000000..79b7c34 --- /dev/null +++ b/tests/exclude-fields.test.ts @@ -0,0 +1,147 @@ +import {describe, expect, it} from 'vitest'; +import {generateOpenApiSchema} from '../src/lib/index.js'; + +const schemaWithSensitiveFields = ` +datasource db { + provider = "postgresql" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + /// @openapi.ignore + password String +} +`; + +const schemaWithAnnotationAndDescription = ` +datasource db { + provider = "postgresql" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + /// Internal hash @openapi.ignore + hash String +} +`; + +const schemaForConfigExclusion = ` +datasource db { + provider = "postgresql" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + password String +} +`; + +const schemaForCombined = ` +datasource db { + provider = "postgresql" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + /// @openapi.ignore + password String + secret String +} +`; + +describe('Field exclusion', () => { + describe('@openapi.ignore annotation', () => { + it('should omit field with @openapi.ignore from properties', async () => { + const result = await generateOpenApiSchema(schemaWithSensitiveFields, { + generateJson: true, + generateYaml: false, + }); + + const parsed = JSON.parse(result); + const userSchema = parsed.components?.schemas?.User; + expect(userSchema.properties).toHaveProperty('id'); + expect(userSchema.properties).toHaveProperty('email'); + expect(userSchema.properties).toHaveProperty('name'); + expect(userSchema.properties).not.toHaveProperty('password'); + }); + + it('should omit field with @openapi.ignore from required array', async () => { + const result = await generateOpenApiSchema(schemaWithSensitiveFields, { + generateJson: true, + generateYaml: false, + }); + + const parsed = JSON.parse(result); + const userSchema = parsed.components?.schemas?.User; + expect(userSchema.required).toContain('id'); + expect(userSchema.required).toContain('email'); + expect(userSchema.required).not.toContain('password'); + }); + + it('should omit field when @openapi.ignore appears alongside a description', async () => { + const result = await generateOpenApiSchema( + schemaWithAnnotationAndDescription, + { + generateJson: true, + generateYaml: false, + }, + ); + + const parsed = JSON.parse(result); + const userSchema = parsed.components?.schemas?.User; + expect(userSchema.properties).not.toHaveProperty('hash'); + expect(userSchema.required).not.toContain('hash'); + }); + }); + + describe('excludeFields config option', () => { + it('should omit field specified in excludeFields from properties', async () => { + const result = await generateOpenApiSchema(schemaForConfigExclusion, { + excludeFields: 'User.password', + generateJson: true, + generateYaml: false, + }); + + const parsed = JSON.parse(result); + const userSchema = parsed.components?.schemas?.User; + expect(userSchema.properties).toHaveProperty('id'); + expect(userSchema.properties).toHaveProperty('email'); + expect(userSchema.properties).not.toHaveProperty('password'); + }); + + it('should omit field specified in excludeFields from required array', async () => { + const result = await generateOpenApiSchema(schemaForConfigExclusion, { + excludeFields: 'User.password', + generateJson: true, + generateYaml: false, + }); + + const parsed = JSON.parse(result); + const userSchema = parsed.components?.schemas?.User; + expect(userSchema.required).not.toContain('password'); + }); + }); + + describe('combined annotation and config', () => { + it('should omit fields from both @openapi.ignore and excludeFields', async () => { + const result = await generateOpenApiSchema(schemaForCombined, { + excludeFields: 'User.secret', + generateJson: true, + generateYaml: false, + }); + + const parsed = JSON.parse(result); + const userSchema = parsed.components?.schemas?.User; + expect(userSchema.properties).toHaveProperty('id'); + expect(userSchema.properties).toHaveProperty('email'); + expect(userSchema.properties).not.toHaveProperty('password'); + expect(userSchema.properties).not.toHaveProperty('secret'); + }); + }); +}); diff --git a/xo.config.js b/xo.config.js index 628ff8a..4dae089 100644 --- a/xo.config.js +++ b/xo.config.js @@ -1,7 +1,7 @@ /** @type {import('xo').Options} */ const xoConfig = { prettier: true, - ignores: ['dist', 'xo.config.js'], + ignores: ['dist', 'xo.config.js', 'esbuild.config.mjs'], rules: { '@typescript-eslint/consistent-type-assertions': 'warn', '@typescript-eslint/no-unsafe-assignment': 'off',