Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/api-docs-module-crd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CrdDefinitionWidget definition={defaultsAndEnumCrd} />,
);
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(
<CrdDefinitionWidget definition={simplifiedDefaultsCrd} />,
);
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(
<CrdDefinitionWidget definition={nullDefaultCrd} />,
);
await user.click(screen.getByText('+ expand all'));

await waitFor(() => {
expect(screen.getByText('default: null')).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -162,6 +180,10 @@ interface CRDSchema {
items?: CRDSchema;
Required?: string[];
required?: string[];
Default?: unknown;
default?: unknown;
Enum?: unknown[];
enum?: unknown[];
}

interface CRDVersion {
Expand All @@ -183,16 +205,40 @@ 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,
Description: schema.Description || schema.description,
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) {
Expand Down Expand Up @@ -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<SchemaPartProps> = ({
propertyKey,
property,
Expand All @@ -381,29 +432,46 @@ const SchemaPart: React.FC<SchemaPartProps> = ({
}) => {
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),
Expand Down Expand Up @@ -473,6 +541,13 @@ const SchemaPart: React.FC<SchemaPartProps> = ({
className={classes.requiredChip}
/>
)}
{defaultValue !== undefined && (
<Chip
label={`default: ${defaultValue}`}
size="small"
className={classes.defaultChip}
/>
)}
<Button
size="small"
className={classes.linkButton}
Expand All @@ -489,6 +564,21 @@ const SchemaPart: React.FC<SchemaPartProps> = ({
<Box id={slug} className={classes.description}>
<ReactMarkdown>{getDescription(property)}</ReactMarkdown>
</Box>
{enumValues && enumValues.length > 0 && (
<Box className={classes.enumContainer}>
<Typography variant="caption" color="textSecondary">
Allowed values:
</Typography>
{enumValues.map(value => (
<Chip
key={value}
label={value}
size="small"
className={classes.enumChip}
/>
))}
</Box>
)}
{propKeys.length > 0 && (
<Box>
{propKeys.map(propKey => (
Expand Down
1 change: 1 addition & 0 deletions site/docs/plugins/api-docs-module-crd/frontend/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The API Docs Module for CRDs is a frontend module that extends the Backstage API
- Expandable/collapsible property tree
- Type and description display for each property
- Required field indicators
- Default value and allowed enum values display for each property
- Nested object and array support
- Anchor links for sharing specific properties

Expand Down
2 changes: 2 additions & 0 deletions site/docs/plugins/api-docs-module-crd/frontend/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ spec:
- Use enums for fixed value sets
- Add validation rules (min, max, pattern)

Required status, default values and enum allowed values are all surfaced in the rendered schema, so the guidance above directly improves the generated documentation.

## Example: Complete CRD Entity

```yaml
Expand Down
2 changes: 1 addition & 1 deletion site/docs/plugins/api-docs-module-crd/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The API Docs Module for Custom Resource Definitions (CRDs) extends the Backstage
- **Multi-Version Support**: Switch between different CRD versions and view version-specific schemas
- **Example YAML Generation**: Automatically generate valid Custom Resource YAML templates from CRD schemas
- **Kubernetes Format Support**: Parses both simplified and standard Kubernetes CRD formats
- **Property Exploration**: View detailed information about each CRD property including type, description, and required status
- **Property Exploration**: View detailed information about each CRD property including type, description, required status, default value, and allowed enum values
- **Dark Mode Support**: Fully styled for both light and dark themes
- **Direct Link Sharing**: Copy links to specific CRD properties for easy reference

Expand Down