Skip to content
Open
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
16 changes: 16 additions & 0 deletions packages/docs-site/src/availability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { availabilityLabel } from './availability.ts';

test('labels every Fern endpoint availability status', () => {
assert.equal(availabilityLabel('alpha'), 'Alpha');
assert.equal(availabilityLabel('beta'), 'Beta');
assert.equal(availabilityLabel('preview'), 'Preview');
assert.equal(availabilityLabel('generally-available'), 'Generally Available');
assert.equal(availabilityLabel('deprecated'), 'Deprecated');
assert.equal(availabilityLabel('legacy'), 'Legacy');
});

test('keeps unknown statuses readable', () => {
assert.equal(availabilityLabel('custom-channel'), 'custom channel');
});
14 changes: 14 additions & 0 deletions packages/docs-site/src/availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** Display labels for Fern endpoint availability statuses. */
const AVAILABILITY_LABELS: Record<string, string> = {
alpha: 'Alpha',
beta: 'Beta',
preview: 'Preview',
'generally-available': 'Generally Available',
deprecated: 'Deprecated',
legacy: 'Legacy',
};

/** Human label for an availability status. Unknown statuses keep their words. */
export function availabilityLabel(status: string): string {
return AVAILABILITY_LABELS[status] ?? status.replaceAll('-', ' ');
}
3 changes: 2 additions & 1 deletion packages/docs-site/src/components/Operation.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AnchorHeading from '@astrojs/starlight/components/AnchorHeading.astro';
import { Aside } from '@astrojs/starlight/components';
import type { FernPageSchema } from 'astro-fern';
import { DEFER_THRESHOLD, nodeCount, operationView, responseSummary, topLevelNodes } from '../operation-sections.ts';
import { availabilityLabel } from '../availability.ts';
import CodeSample from './CodeSample.astro';
import SchemaSectionIsland from './SchemaSectionIsland.astro';
import SchemaTree from './SchemaTree.astro';
Expand Down Expand Up @@ -36,7 +37,7 @@ const availabilityMessage = !op.deprecated ? op.availability?.message : undefine
}
{
availabilityMessage ? (
<Aside type="note" title={op.availability?.status.replaceAll('-', ' ')}>
<Aside type="note" title={op.availability ? availabilityLabel(op.availability.status) : 'Availability'}>
<p>{availabilityMessage}</p>
</Aside>
) : null
Expand Down
19 changes: 8 additions & 11 deletions packages/docs-site/src/components/PageTitle.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import Default from '@astrojs/starlight/components/PageTitle.astro';
import { getApiPage, getApiRouter } from '../api-server.ts';
import { availabilityLabel } from '../availability.ts';

const router = await getApiRouter();
const resolved = await getApiPage(Astro.url, Astro.request, Astro.locals);
Expand All @@ -26,18 +27,8 @@ const markdownHref =
page.operation.placement?.projectionId,
)
: undefined;
const availabilityLabels: Record<string, string> = {
alpha: 'Alpha',
beta: 'Beta',
preview: 'Preview',
'generally-available': 'Generally Available',
deprecated: 'Deprecated',
legacy: 'Legacy',
};
const availabilityStatus = op?.deprecated ? 'deprecated' : op?.availability?.status;
const availability = availabilityStatus
? (availabilityLabels[availabilityStatus] ?? availabilityStatus.replaceAll('-', ' '))
: undefined;
const availability = availabilityStatus ? availabilityLabel(availabilityStatus) : undefined;
---

{
Expand Down Expand Up @@ -224,6 +215,12 @@ const availability = availabilityStatus
--forge-availability-background: var(--cf-danger-muted);
}

.forge-availability[data-status='legacy'] {
--forge-availability-color: var(--cf-muted-foreground);
--forge-availability-background: var(--cf-surface-sunken);
border-style: dashed;
}

.forge-endpoint {
min-width: 0;
gap: 0.625rem;
Expand Down
13 changes: 11 additions & 2 deletions packages/fern-forge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ Primary operation metadata accepts these fields:
| ------------------------------ | ----------------------------------------------- | --------------------- | --------------------------------------------------------- |
| `x-fern-sdk-group-name` | Nonempty string with segments separated by dots | `sdkGroupName` | Selects products and supplies the SDK accessor path |
| `x-fern-sdk-method-name` | Nonempty string | `sdkMethodName` | Supplies the SDK method for snippets |
| `x-fern-availability` | Availability value below | `availability` | Supplies lifecycle metadata |
| `x-fern-availability` | Status string, or `{ status, message }` | `availability`, `availabilityMessage` | Supplies lifecycle metadata. `message` is kept when the object form is used. |
| `x-fern-ignore` | Boolean, default `false` | `ignore` | Removes the operation from generated content when true |
| `x-forge-hidden` | Boolean, default `false` | `hidden` | Stores approval state and lowers route-collision priority |
| `x-forge-internal` | Boolean | `internal` | Stores internal visibility metadata |
Expand All @@ -256,7 +256,10 @@ projections always require both names.

### Availability

`x-fern-availability` accepts:
`x-fern-availability` accepts Fern's endpoint vocabulary as a status string or as
`{ status, message }`. The message is the human note Fern shows beside the
status, such as a replacement endpoint. `availability` stores the status.
`availabilityMessage` stores the note when the object form includes one.

```text
alpha
Expand All @@ -267,6 +270,12 @@ deprecated
legacy
```

```yaml
x-fern-availability:
status: legacy
message: Use the v2 widgets endpoint.
```

## Argument metadata reference

`x-forge-globals` contains arguments. `x-forge-args` contains arguments or
Expand Down
28 changes: 20 additions & 8 deletions packages/fern-forge/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ function aliasName(alias: ForgeOperationDataSchema): string {
return `alias:${encodeURIComponent(alias.sdkGroupName)}/${encodeURIComponent(alias.sdkMethodName)}`;
}

function variantAvailability(
alias: ForgeOperationDataSchema,
): ForgeOperationDataSchema['availability'] | { status: ForgeOperationDataSchema['availability']; message: string } {
if (alias.availability !== undefined && alias.availabilityMessage !== undefined) {
return { status: alias.availability, message: alias.availabilityMessage };
}
return alias.availability;
}

const extension = defineFernExtension<ForgeOperationDataSchema, ForgeExtensionState>({
name: 'forge',
schema: forgeOperationDataSchema,
Expand All @@ -26,14 +35,17 @@ const extension = defineFernExtension<ForgeOperationDataSchema, ForgeExtensionSt
if (!parsed) return undefined;
return {
...(parsed.primary ? { data: parsed.primary, presentation: presentation(parsed.primary) } : {}),
variants: parsed.aliases.map((alias) => ({
name: aliasName(alias),
sdkGroupName: alias.sdkGroupName,
sdkMethodName: alias.sdkMethodName,
...(alias.availability !== undefined ? { availability: alias.availability } : {}),
data: alias,
presentation: presentation(alias),
})),
variants: parsed.aliases.map((alias) => {
const availability = variantAvailability(alias);
return {
name: aliasName(alias),
sdkGroupName: alias.sdkGroupName,
sdkMethodName: alias.sdkMethodName,
...(availability !== undefined ? { availability } : {}),
data: alias,
presentation: presentation(alias),
};
}),
};
},
});
Expand Down
55 changes: 55 additions & 0 deletions packages/fern-forge/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,46 @@ test('validates and exposes every known operation-level Forge field', () => {
assert.equal(forge?.hidden, true);
assert.equal(forge?.internal, true);
assert.equal(forge?.sdkGroupName, 'widgets.items');
assert.equal(forge?.availability, 'deprecated');
assert.equal(forge?.availabilityMessage, undefined);
assert.equal(forge?.params?.widget_id && typeof forge.params.widget_id, 'object');
});

test('keeps Fern availability messages on the operation and on alias projections', () => {
const source = operation({
'x-fern-sdk-group-name': undefined,
'x-fern-sdk-method-name': undefined,
'x-forge-aliases': [
{
'x-fern-sdk-group-name': 'widgets.items',
'x-fern-sdk-method-name': 'update',
'x-fern-availability': { status: 'legacy', message: ' Use the v2 widgets endpoint. ' },
},
],
});
const model = buildDocsModel({ source, products: [product], extensions: [forgeExtension()] });
const result = model.products[0]?.sections[0]?.operations[0];
assert.ok(result);
assert.deepEqual(result.availability, { status: 'legacy', message: 'Use the v2 widgets endpoint.' });
const forge = getOperationExtensionData(result, 'forge');
assert.equal(forge?.availability, 'legacy');
assert.equal(forge?.availabilityMessage, 'Use the v2 widgets endpoint.');
});

test('accepts an availability object on a primary operation', () => {
const model = buildDocsModel({
source: operation({
'x-fern-availability': { status: 'preview', message: 'Subject to change.' },
}),
products: [product],
extensions: [forgeExtension()],
});
const result = model.products[0]?.sections[0]?.operations[0];
assert.ok(result);
assert.deepEqual(result.availability, { status: 'preview', message: 'Subject to change.' });
assert.equal(getOperationExtensionData(result, 'forge')?.availabilityMessage, 'Subject to change.');
});

test('aliases participate in product discovery and retain projection-specific data', () => {
const source = operation({
'x-fern-sdk-group-name': undefined,
Expand Down Expand Up @@ -350,6 +387,24 @@ test('rejects malformed, misplaced, and unknown Forge metadata', () => {
}),
/x-fern-sdk-group-name[\s\S]*expected string/,
);
assert.throws(
() =>
buildDocsModel({
source: operation({ 'x-fern-availability': { status: 'stable', message: 'Ready.' } }),
products: [product],
extensions: [forgeExtension()],
}),
/x-fern-availability/,
);
assert.throws(
() =>
buildDocsModel({
source: operation({ 'x-fern-availability': { status: 'legacy', message: ' ' } }),
products: [product],
extensions: [forgeExtension()],
}),
/x-fern-availability/,
);
});

test('validates known root-level Forge metadata', () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/fern-forge/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,22 @@ export type ForgeExtensionState = {
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const;
const OPERATION_PROJECTION_KEYS = operationProjectionFieldsSchema.keyof().options;

function availabilityFields(
value: ReturnType<typeof projectionSchema>['_output']['x-fern-availability'],
): Pick<ForgeOperationDataSchema, 'availability' | 'availabilityMessage'> {
if (value === undefined) return {};
if (typeof value === 'string') return { availability: value };
return {
availability: value.status,
...(value.message !== undefined ? { availabilityMessage: value.message } : {}),
};
}

function toOperationData(value: ReturnType<typeof projectionSchema>['_output']): ForgeOperationDataSchema {
return forgeOperationDataSchema.parse({
sdkGroupName: value['x-fern-sdk-group-name'],
sdkMethodName: value['x-fern-sdk-method-name'],
...(value['x-fern-availability'] !== undefined ? { availability: value['x-fern-availability'] } : {}),
...availabilityFields(value['x-fern-availability']),
ignore: value['x-fern-ignore'] ?? false,
hidden: value['x-forge-hidden'] ?? false,
...(value['x-forge-internal'] !== undefined ? { internal: value['x-forge-internal'] } : {}),
Expand Down
19 changes: 16 additions & 3 deletions packages/fern-forge/schemas/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from 'astro/zod';
import { argumentSchema, methodArgumentSchema, paramOverrideSchema } from './arguments.ts';
import { nonEmptyStringSchema } from './shared.ts';

/** Availability values accepted by the Forge OpenAPI contract. */
/** Availability statuses accepted by the Forge OpenAPI contract. */
export const forgeAvailabilitySchema = z.enum([
'alpha',
'beta',
Expand All @@ -12,15 +12,28 @@ export const forgeAvailabilitySchema = z.enum([
'legacy',
]);

/** A validated Forge availability value. */
/** A validated Forge availability status. */
export type ForgeAvailabilitySchema = z.infer<typeof forgeAvailabilitySchema>;

/**
* Fern's endpoint availability: a status string, or `{ status, message }`.
* @see https://buildwithfern.com/learn/api-definition/openapi/extensions/availability
*/
const forgeAvailabilityInputSchema = z.union([
forgeAvailabilitySchema,
z.strictObject({
status: forgeAvailabilitySchema,
message: nonEmptyStringSchema.optional(),
}),
]);

/** Normalized, serializable Forge metadata stored on an operation projection. */
export const forgeOperationDataSchema = z
.object({
sdkGroupName: nonEmptyStringSchema,
sdkMethodName: nonEmptyStringSchema,
availability: forgeAvailabilitySchema.optional(),
availabilityMessage: nonEmptyStringSchema.optional(),
ignore: z.boolean(),
hidden: z.boolean(),
internal: z.boolean().optional(),
Expand Down Expand Up @@ -51,7 +64,7 @@ const sdkGroupSchema = nonEmptyStringSchema
export const operationProjectionFieldsSchema = z.object({
'x-fern-sdk-group-name': sdkGroupSchema,
'x-fern-sdk-method-name': nonEmptyStringSchema,
'x-fern-availability': forgeAvailabilitySchema.optional(),
'x-fern-availability': forgeAvailabilityInputSchema.optional(),
'x-fern-ignore': z.boolean().optional(),
'x-forge-hidden': z.boolean().optional(),
'x-forge-internal': z.boolean().optional(),
Expand Down
Loading