diff --git a/plugins/api-docs-module-crd/README.md b/plugins/api-docs-module-crd/README.md index f1847173..e8899f03 100644 --- a/plugins/api-docs-module-crd/README.md +++ b/plugins/api-docs-module-crd/README.md @@ -4,7 +4,7 @@ Welcome to the api-docs-module-crd plugin! [![npm latest version](https://img.shields.io/npm/v/@terasky/backstage-plugin-api-docs-module-crd/latest.svg)](https://www.npmjs.com/package/@terasky/backstage-plugin-api-docs-module-crd) -The `api-docs-module-crd` plugin is a frontend module that extends the Backstage API Docs plugin with support for Kubernetes Custom Resource Definitions (CRDs). It provides an interactive visualization of CRD schemas similar to doc.crds.dev, with features like multi-version support, property exploration, and example YAML generation. +The `api-docs-module-crd` plugin is a frontend module that extends the Backstage API Docs plugin with support for Kubernetes Custom Resource Definitions (CRDs). It provides an interactive visualization of CRD schemas similar to doc.crds.dev, with features like multi-version support, property exploration (including each field's default and allowed enum values), and example YAML generation. For detailed docs go to https://terasky-oss.github.io/backstage-plugins/plugins/api-docs-module-crd/overview diff --git a/plugins/api-docs-module-crd/src/components/CrdDefinitionWidget/CrdDefinitionWidget.test.tsx b/plugins/api-docs-module-crd/src/components/CrdDefinitionWidget/CrdDefinitionWidget.test.tsx index fab137ad..a057b33b 100644 --- a/plugins/api-docs-module-crd/src/components/CrdDefinitionWidget/CrdDefinitionWidget.test.tsx +++ b/plugins/api-docs-module-crd/src/components/CrdDefinitionWidget/CrdDefinitionWidget.test.tsx @@ -473,4 +473,107 @@ Schema: expect(yamlContent).toContain('simpleArray: []'); }); }); + + it('should render default values and allowed enum values (Kubernetes format)', async () => { + const user = userEvent.setup(); + const defaultsAndEnumCrd = ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: myresources.example.com +spec: + group: example.com + names: + kind: MyResource + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + paused: + type: boolean + default: false + strategy: + type: string + default: RollingUpdate + enum: + - RollingUpdate + - Recreate +`; + + await renderInTestApp( + , + ); + await user.click(screen.getByText('+ expand all')); + + await waitFor(() => { + // A falsy default must survive (regression guard for the `??` merge). + expect(screen.getByText('default: false')).toBeInTheDocument(); + expect(screen.getByText('default: RollingUpdate')).toBeInTheDocument(); + expect(screen.getByText('Allowed values:')).toBeInTheDocument(); + // "Recreate" only appears as an enum value, never as a default. + expect(screen.getByText('Recreate')).toBeInTheDocument(); + }); + }); + + it('should render default values from the simplified schema format', async () => { + const user = userEvent.setup(); + const simplifiedDefaultsCrd = ` +Kind: MyResource +Group: example.com +Version: v1 +Schema: + Type: object + Properties: + spec: + Type: object + Properties: + replicas: + Type: integer + Default: 3 +`; + + await renderInTestApp( + , + ); + await user.click(screen.getByText('+ expand all')); + + await waitFor(() => { + expect(screen.getByText('default: 3')).toBeInTheDocument(); + }); + }); + + it('should preserve an explicitly configured null default', async () => { + const user = userEvent.setup(); + const nullDefaultCrd = ` +Kind: MyResource +Group: example.com +Version: v1 +Schema: + Type: object + Properties: + spec: + Type: object + Properties: + nullableField: + Type: string + Default: null +`; + + await renderInTestApp( + , + ); + await user.click(screen.getByText('+ expand all')); + + await waitFor(() => { + expect(screen.getByText('default: null')).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/api-docs-module-crd/src/components/CrdDefinitionWidget/CrdDefinitionWidget.tsx b/plugins/api-docs-module-crd/src/components/CrdDefinitionWidget/CrdDefinitionWidget.tsx index f1d558e7..072f521a 100644 --- a/plugins/api-docs-module-crd/src/components/CrdDefinitionWidget/CrdDefinitionWidget.tsx +++ b/plugins/api-docs-module-crd/src/components/CrdDefinitionWidget/CrdDefinitionWidget.tsx @@ -110,6 +110,24 @@ const useStyles = makeStyles(theme => ({ backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, }, + defaultChip: { + fontFamily: 'monospace', + backgroundColor: theme.palette.type === 'dark' + ? theme.palette.grey[700] + : theme.palette.grey[100], + color: theme.palette.text.secondary, + fontWeight: 500, + }, + enumContainer: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + marginBottom: theme.spacing(1), + }, + enumChip: { + fontFamily: 'monospace', + }, linkButton: { marginLeft: 'auto', minWidth: 'auto', @@ -162,6 +180,10 @@ interface CRDSchema { items?: CRDSchema; Required?: string[]; required?: string[]; + Default?: unknown; + default?: unknown; + Enum?: unknown[]; + enum?: unknown[]; } interface CRDVersion { @@ -183,6 +205,12 @@ function getDescription(schema: CRDSchema): string { return schema.Description?.trim() || schema.description?.trim() || '_No Description Provided._'; } +/** + * Collapses the two schema key casings this widget accepts into one canonical + * shape. The simplified format uses capitalised keys (Type, Properties, …) and + * the Kubernetes openAPIV3Schema format uses lowercase ones; every consumer + * reads the capitalised fields returned here. + */ function normalizeSchema(schema: CRDSchema): CRDSchema { return { Type: schema.Type || schema.type, @@ -190,9 +218,27 @@ function normalizeSchema(schema: CRDSchema): CRDSchema { Properties: schema.Properties || schema.properties, Items: schema.Items || (schema.items ? { Schema: schema.items } : undefined), Required: schema.Required || schema.required, + // Select by key presence, not ??, so a falsy default (false, 0, "") and an + // explicitly configured `Default: null` are both preserved rather than + // being treated as absent and dropped. + Default: Object.prototype.hasOwnProperty.call(schema, 'Default') + ? schema.Default + : schema.default, + Enum: schema.Enum ?? schema.enum, }; } +/** + * Renders a schema default for display. Strings are shown verbatim (an empty + * string as `""`, so it is not mistaken for "no default"); everything else is + * JSON-encoded. Returns undefined when no default is set. + */ +function formatDefault(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value === 'string') return value === '' ? '""' : value; + return JSON.stringify(value); +} + function parseCRDData(data: any): ParsedCRDData | null { // Check if it's the simplified format if (data.Kind && data.Group && data.Version) { @@ -371,6 +417,11 @@ interface SchemaPartProps { collapseAll: boolean; } +/** + * Renders a single schema property as an expandable accordion: its name, type, + * required flag, default and enum allowed values, description, and, recursively, + * any nested object or array-item properties. + */ const SchemaPart: React.FC = ({ propertyKey, property, @@ -381,29 +432,46 @@ const SchemaPart: React.FC = ({ }) => { const classes = useStyles(); - const [props, propKeys, required, type, schema] = useMemo(() => { - const normalized = normalizeSchema(property); - let currentSchema = normalized; - let currentProps = normalized.Properties || {}; - let currentType = normalized.Type || 'string'; - - if (currentType === 'array' && normalized.Items?.Schema) { - const itemsSchema = normalizeSchema(normalized.Items.Schema); - if (itemsSchema.Type !== 'object') { - currentType = `[]${itemsSchema.Type}`; - } else { - currentSchema = itemsSchema; - currentProps = itemsSchema.Properties || {}; - currentType = '[]object'; + const [props, propKeys, required, type, schema, defaultValue, enumValues] = + useMemo(() => { + const normalized = normalizeSchema(property); + let currentSchema = normalized; + let currentProps = normalized.Properties || {}; + let currentType = normalized.Type || 'string'; + + if (currentType === 'array' && normalized.Items?.Schema) { + const itemsSchema = normalizeSchema(normalized.Items.Schema); + if (itemsSchema.Type !== 'object') { + currentType = `[]${itemsSchema.Type}`; + } else { + currentSchema = itemsSchema; + currentProps = itemsSchema.Properties || {}; + currentType = '[]object'; + } } - } - - const currentPropKeys = Object.keys(currentProps); - const normalizedParent = parent ? normalizeSchema(parent) : undefined; - const isRequired = normalizedParent?.Required?.includes(propertyKey) || false; - return [currentProps, currentPropKeys, isRequired, currentType, currentSchema]; - }, [parent, property, propertyKey]); + const currentPropKeys = Object.keys(currentProps); + const normalizedParent = parent ? normalizeSchema(parent) : undefined; + const isRequired = + normalizedParent?.Required?.includes(propertyKey) || false; + + // Default and enum belong to the property itself, so read them from the + // property's own schema rather than the array item schema resolved above. + const propDefault = formatDefault(normalized.Default); + const propEnum = normalized.Enum?.map(v => + typeof v === 'string' ? v : JSON.stringify(v), + ); + + return [ + currentProps, + currentPropKeys, + isRequired, + currentType, + currentSchema, + propDefault, + propEnum, + ] as const; + }, [parent, property, propertyKey]); const slug = useMemo( () => slugify((parentSlug ? `${parentSlug}-` : '') + propertyKey), @@ -473,6 +541,13 @@ const SchemaPart: React.FC = ({ className={classes.requiredChip} /> )} + {defaultValue !== undefined && ( + + )}