Skip to content

Commit

Permalink
feat(cli): support generics in fern definition (#4512)
Browse files Browse the repository at this point in the history
* ready to go, merge from main

* prettier

* do not match on reserved keywords

* addressed comments

* no unused generic rule

* uncomment tests

* add no unused generics

* added rule for all non-aliases and all non-alias type contained values

* more test cases

* consolidate logic

* remove one more regex

* add more validation rules -- for number of applied arguments and if no arguments are applied

* add a couple more rules around number of args, no applied args

* chore(cli): reduce duplication with generic checks (#4525)

* addressed comments

* slightly cleaner error message

* added documentation and changelog version

* more real world example

* remove inlined literal

* more tangible example

---------

Co-authored-by: Deep Singhvi <[email protected]>
  • Loading branch information
RohinBhargava and dsinghvi authored Sep 4, 2024
1 parent 55e30cc commit 4a6682a
Show file tree
Hide file tree
Showing 39 changed files with 2,857 additions and 30 deletions.
40 changes: 40 additions & 0 deletions fern/pages/api-definition/fern-definition/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,46 @@ MyUnion:
- string
- integer
```

### Generics

Fern supports shallow generic objects, to minimize code duplication. You can
define a generic for reuse like so:

```yaml
MySpecialMapItem<Key, Value>:
properties:
key: Key,
value: Value,
diagnostics: string
```

Now, you can instantiate generic types as a type alias:

```yaml
StringIntegerMapItem:
type: Response<string, number>
StringStringMapItem:
type: Response<string, string>
```

You can now freely use this type as if it were any other type! Note, generated
code will not use generics. The above example will be generated in typescript as:

```typescript
type StringIntegerMapI = {
key: string,
value: number,
diagnostics: string
}
type StringResponse = {
input: string,
output: string,
diagnostics: string
}
```

### Documenting types

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/cli/versions.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
- changelog_entry:
- summary: |
Adds generic object declarations to the fern definition. Now we can define generics and
use them in alias declarations to minimize code duplication.
type: feat
created_at: '2024-09-04'
ir_version: 53
version: 0.41.0

- changelog_entry:
- summary: |
Fix an issue where some postman environment variables (e.g. API key) were not substituted
Expand Down
1 change: 1 addition & 0 deletions packages/cli/fern-definition/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { EXAMPLE_REFERENCE_PREFIX, YAML_SCHEMA_VERSION } from "./constants";
export { type NodePath, type NodePathItem } from "./NodePath";
export * as RawSchemas from "./schemas";
export * from "./schemas/file-schemas";
export * from "./utils/generics";
export { getRequestBody } from "./utils/getRequestBody";
export { isInlineRequestBody } from "./utils/isInlineRequestBody";
export { isRawProtobufSourceSchema } from "./utils/isRawProtobufSourceSchema";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const RawContainerType = {
optional: "optional",
set: "set",
list: "list",
map: "map",
literal: "literal"
} as const;

export const RawContanerTypes: Set<string> = new Set(Object.values(RawContainerType));

Check warning on line 9 in packages/cli/fern-definition/schema/src/utils/RawContainerType.ts

View workflow job for this annotation

GitHub Actions / eslint

The generic type arguments should be specified as part of the constructor type arguments
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { parseGeneric } from "./parseGeneric";
export { isGeneric } from "./isGeneric";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { parseGeneric } from "./parseGeneric";

export function isGeneric(name: string): boolean {
return parseGeneric(name) != null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { RawContanerTypes } from "../RawContainerType";

export declare namespace ParseGeneric {
interface Return {
name: string;
arguments: string[];
}
}

export function parseGeneric(name: string): ParseGeneric.Return | undefined {
const genericMatch = name.match(/([\w.]+)<([\w,\s]+)>/);

if (
genericMatch?.[0] != null &&
genericMatch[1] != null &&
genericMatch[2] != null &&
!RawContanerTypes.has(genericMatch[1].trim())
) {
return {
name: genericMatch[1].trim(),
arguments: genericMatch[2].split(",").map((arg) => arg.trim())
};
}

return undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface DefinitionFileAstNodeTypes {
typeDeclaration: {
typeName: TypeDeclarationName;
declaration: RawSchemas.TypeDeclarationSchema;
nodePath?: NodePath;
};
exampleType: {
typeName: string;
Expand All @@ -23,6 +24,7 @@ export interface DefinitionFileAstNodeTypes {
_default?: unknown;
validation?: RawSchemas.ValidationSchema;
location?: TypeReferenceLocation;
nodePath?: NodePath;
};
typeName: string;
httpService: RawSchemas.HttpServiceSchema;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export function createTypeReferenceVisitor(
typeReference,
_default,
validation,
location
location,
nodePath
},
nodePath
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export async function visitTypeDeclaration({
}): Promise<void> {
const visitTypeReference = createTypeReferenceVisitor(visitor);

await visitor.typeDeclaration?.({ typeName: { isInlined: false, name: typeName }, declaration }, nodePathForType);
await visitor.typeDeclaration?.(
{ typeName: { isInlined: false, name: typeName }, declaration, nodePath: nodePathForType },
nodePathForType
);

const visitExamples = async (examples: RawSchemas.ExampleTypeSchema[] | undefined) => {
if (examples == null) {
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/fern-definition/validator/src/getAllRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { NoUndefinedExampleReferenceRule } from "./rules/no-undefined-example-re
import { NoUndefinedPathParametersRule } from "./rules/no-undefined-path-parameters";
import { NoUndefinedTypeReferenceRule } from "./rules/no-undefined-type-reference";
import { NoUndefinedVariableReferenceRule } from "./rules/no-undefined-variable-reference";
import { NoUnusedGenericRule } from "./rules/no-unused-generic";
import { OnlyObjectExtensionsRule } from "./rules/only-object-extensions";
import { ValidBasePathRule } from "./rules/valid-base-path";
import { ValidDefaultEnvironmentRule } from "./rules/valid-default-environment";
Expand All @@ -31,6 +32,7 @@ import { ValidExampleEndpointCallRule } from "./rules/valid-example-endpoint-cal
import { ValidExampleErrorRule } from "./rules/valid-example-error";
import { ValidExampleTypeRule } from "./rules/valid-example-type";
import { ValidFieldNamesRule } from "./rules/valid-field-names";
import { ValidGenericRule } from "./rules/valid-generic";
import { ValidNavigationRule } from "./rules/valid-navigation";
import { ValidOauthRule } from "./rules/valid-oauth";
import { ValidPaginationRule } from "./rules/valid-pagination";
Expand Down Expand Up @@ -81,7 +83,9 @@ export function getAllRules(): Rule[] {
ValidExampleErrorRule,
ValidTypeReferenceWithDefaultAndValidationRule,
ValidStreamConditionRule,
ValidVersionRule
ValidVersionRule,
NoUnusedGenericRule,
ValidGenericRule
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
types:
GenericApplication:
type: GenericUsedType<string>

GenericUsedType<T>:
properties:
foo: T
bar: string
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { parseReferenceToTypeName } from "@fern-api/ir-generator";
import { FernWorkspace, visitAllDefinitionFiles } from "@fern-api/workspace-loader";
import {
isRawTextType,
NodePath,
parseRawBytesType,
parseRawFileType,
recursivelyVisitRawTypeReference
recursivelyVisitRawTypeReference,
parseGeneric
} from "@fern-api/fern-definition-schema";
import { visitDefinitionFileYamlAst, TypeReferenceLocation } from "../../ast";
import chalk from "chalk";
Expand All @@ -27,13 +29,38 @@ export const NoUndefinedTypeReferenceRule: Rule = {
if (typesForFilepath == null) {
return false;
}
const maybeGeneric = parseGeneric(reference.parsed.typeName);
if (maybeGeneric != null) {
return maybeGeneric.name ? typesForFilepath.has(maybeGeneric.name) : false;
}
return typesForFilepath.has(reference.parsed.typeName);
}

function checkGenericType(reference: ReferenceToTypeName, nodePath?: NodePath) {
if (nodePath != null) {
const mutableNodePath = [...nodePath];
while (mutableNodePath.length > 0) {
const nodePathItem = mutableNodePath.pop();
const maybeGeneric = nodePathItem
? typeof nodePathItem === "string"
? parseGeneric(nodePathItem)
: parseGeneric(nodePathItem.key)
: undefined;
if (maybeGeneric != null) {
return (
reference.parsed?.typeName && maybeGeneric.arguments?.includes(reference.parsed?.typeName)
);
}
}
}
return false;
}

return {
definitionFile: {
typeReference: ({ typeReference, location }, { relativeFilepath, contents }) => {
typeReference: ({ typeReference, location, nodePath }, { relativeFilepath, contents }) => {
const parsedRawFileType = parseRawFileType(typeReference);

if (parsedRawFileType != null) {
if (location === TypeReferenceLocation.InlinedRequestProperty) {
return [];
Expand Down Expand Up @@ -98,7 +125,7 @@ export const NoUndefinedTypeReferenceRule: Rule = {
severity: "error",
message: "The text type can only be used as a response-stream or response."
});
} else if (!doesTypeExist(namedType)) {
} else if (!doesTypeExist(namedType) && !checkGenericType(namedType, nodePath)) {
violations.push({
severity: "error",
message: `Type ${chalk.bold(
Expand All @@ -124,6 +151,12 @@ async function getTypesByFilepath(workspace: FernWorkspace) {
await visitDefinitionFileYamlAst(file, {
typeDeclaration: ({ typeName }) => {
if (!typeName.isInlined) {
const maybeGenericDeclaration = parseGeneric(typeName.name);
if (maybeGenericDeclaration != null) {
if (maybeGenericDeclaration.name) {
typesForFile.add(maybeGenericDeclaration.name);
}
}
typesForFile.add(typeName.name);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
imports:
two: ./2.yml

types:
GenericApplication:
type: two.GenericUsedType<string>

AnotherGenericUnusedType<T>:
properties:
foo: T
bar: list<string>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
types:
GenericUsedType<T>:
properties:
foo: T
bar: string

GenericUnusedType<T>:
properties:
foo: T
bar: number
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
name: simple-api
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils";
import { getViolationsForRule } from "../../../testing-utils/getViolationsForRule";
import { NoUnusedGenericRule } from "../no-unused-generic";

describe("no-unused-generic", () => {
it("simple", async () => {
const violations = await getViolationsForRule({
rule: NoUnusedGenericRule,
absolutePathToWorkspace: join(
AbsoluteFilePath.of(__dirname),
RelativeFilePath.of("fixtures"),
RelativeFilePath.of("simple")
)
});

expect(violations).toEqual([
{
message: 'Generic "AnotherGenericUnusedType<T>" is declared but never used.',
nodePath: ["types", "AnotherGenericUnusedType<T>"],
relativeFilepath: RelativeFilePath.of("1.yml"),
severity: "error"
},
{
message: 'Generic "GenericUnusedType<T>" is declared but never used.',
nodePath: ["types", "GenericUnusedType<T>"],
relativeFilepath: RelativeFilePath.of("2.yml"),
severity: "error"
}
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NoUnusedGenericRule } from "./no-unused-generic";
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { visitAllDefinitionFiles } from "@fern-api/workspace-loader";
import { visitDefinitionFileYamlAst } from "../../ast";
import { Rule, RuleViolation } from "../../Rule";
import { visitRawTypeDeclaration, parseGeneric } from "@fern-api/fern-definition-schema";

export const NoUnusedGenericRule: Rule = {
name: "no-unused-generic",
create: async ({ workspace }) => {
const instantiations = new Set();

await visitAllDefinitionFiles(workspace, async (_, file) => {
await visitDefinitionFileYamlAst(file, {
typeDeclaration: (type) => {
visitRawTypeDeclaration(type.declaration, {
alias: (alias) => {
const maybeGenericDeclaration = parseGeneric(
typeof alias === "string" ? alias : alias.type
);
if (maybeGenericDeclaration != null && maybeGenericDeclaration.name) {
const [maybeTypeName, typeName, ..._rest] = maybeGenericDeclaration.name.split(".");
const key = typeName ?? maybeTypeName;
if (key) {
instantiations.add(key);
}
}
},
enum: () => {},
object: () => {},
discriminatedUnion: () => {},
undiscriminatedUnion: () => {}
});
}
});
});

return {
definitionFile: {
typeName: (name): RuleViolation[] => {
const maybeGenericDeclaration = parseGeneric(name);
if (maybeGenericDeclaration == null) {
return [];
}

return maybeGenericDeclaration.name && instantiations.has(maybeGenericDeclaration.name)
? []
: [
{
severity: "error",
message: `Generic "${name}" is declared but never used.`
}
];
}
}
};
}
};
Loading

0 comments on commit 4a6682a

Please sign in to comment.