From 80b416f4cf7dedded5d3fa6e9b29b238aac311ed Mon Sep 17 00:00:00 2001 From: Alex McKinney Date: Thu, 18 Apr 2024 14:49:38 -0700 Subject: [PATCH 1/3] (feature): Add OAuth YAML and validator --- .../src/converters/convertApiAuth.ts | 23 ++ .../cli/generation/ir-generator/src/index.ts | 3 + .../src/resolvers/EndpointResolver.ts | 89 +++++ .../src/resolvers/ResolvedEndpoint.ts | 6 + .../src/utils/parseReferenceToEndpointName.ts | 41 +++ .../cli/yaml/validator/src/getAllRules.ts | 2 + .../fixtures/invalid/definition/api.yml | 22 ++ .../fixtures/invalid/definition/auth.yml | 41 +++ .../fixtures/valid/definition/api.yml | 22 ++ .../fixtures/valid/definition/auth.yml | 41 +++ .../__test__/fixtures/valid/generators.yml | 1 + .../valid-oauth/__test__/valid-oauth.test.ts | 74 ++++ .../validator/src/rules/valid-oauth/index.ts | 1 + .../src/rules/valid-oauth/valid-oauth.ts | 153 +++++++++ .../valid-oauth/validateClientCredentials.ts | 51 +++ .../validateRefreshTokenEndpoint.ts | 89 +++++ .../valid-oauth/validateTokenEndpoint.ts | 75 ++++ .../src/rules/valid-oauth/validateUtils.ts | 239 +++++++++++++ .../validateCursorPagination.ts | 8 +- .../validateOffsetPagination.ts | 7 +- .../rules/valid-pagination/validateUtils.ts | 222 +----------- .../src/utils/propertyValidatorUtils.ts | 323 ++++++++++++++++++ .../schemas/AuthSchemeDeclarationSchema.ts | 2 + .../OAuthAccessTokenPropertiesSchema.ts | 9 + .../schemas/OAuthClientCredentialsSchema.ts | 16 + .../schemas/OAuthGetTokenEndpointSchema.ts | 9 + .../OAuthRefreshTokenEndpointSchema.ts | 11 + .../OAuthRefreshTokenPropertiesSchema.ts | 7 + .../src/schemas/OAuthSchemeSchema.ts | 6 + .../cli/yaml/yaml-schema/src/schemas/index.ts | 4 + .../utils/visitRawAuthSchemeDeclaration.ts | 4 + 31 files changed, 1378 insertions(+), 223 deletions(-) create mode 100644 packages/cli/generation/ir-generator/src/resolvers/EndpointResolver.ts create mode 100644 packages/cli/generation/ir-generator/src/resolvers/ResolvedEndpoint.ts create mode 100644 packages/cli/generation/ir-generator/src/utils/parseReferenceToEndpointName.ts create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/invalid/definition/api.yml create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/invalid/definition/auth.yml create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/definition/api.yml create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/definition/auth.yml create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/generators.yml create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/__test__/valid-oauth.test.ts create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/index.ts create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/validateClientCredentials.ts create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/validateRefreshTokenEndpoint.ts create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/validateTokenEndpoint.ts create mode 100644 packages/cli/yaml/validator/src/rules/valid-oauth/validateUtils.ts create mode 100644 packages/cli/yaml/validator/src/utils/propertyValidatorUtils.ts create mode 100644 packages/cli/yaml/yaml-schema/src/schemas/OAuthAccessTokenPropertiesSchema.ts create mode 100644 packages/cli/yaml/yaml-schema/src/schemas/OAuthClientCredentialsSchema.ts create mode 100644 packages/cli/yaml/yaml-schema/src/schemas/OAuthGetTokenEndpointSchema.ts create mode 100644 packages/cli/yaml/yaml-schema/src/schemas/OAuthRefreshTokenEndpointSchema.ts create mode 100644 packages/cli/yaml/yaml-schema/src/schemas/OAuthRefreshTokenPropertiesSchema.ts create mode 100644 packages/cli/yaml/yaml-schema/src/schemas/OAuthSchemeSchema.ts diff --git a/packages/cli/generation/ir-generator/src/converters/convertApiAuth.ts b/packages/cli/generation/ir-generator/src/converters/convertApiAuth.ts index 052eee87ed..00e267f0a9 100644 --- a/packages/cli/generation/ir-generator/src/converters/convertApiAuth.ts +++ b/packages/cli/generation/ir-generator/src/converters/convertApiAuth.ts @@ -81,6 +81,12 @@ function convertSchemeReference({ file, docs, rawScheme + }), + oauth: (rawScheme) => + generateOAuth({ + file, + docs, + rawScheme }) }); }; @@ -105,6 +111,23 @@ function convertSchemeReference({ } } +function generateOAuth({ + docs, + file, + rawScheme +}: { + docs: string | undefined; + file: FernFileContext; + rawScheme: RawSchemas.OAuthSchemeSchema | undefined; +}): AuthScheme.Bearer { + // TODO: OAuth is not implemented yet. Return the default bearer auth for now. + return AuthScheme.bearer({ + docs, + token: file.casingsGenerator.generateName("token"), + tokenEnvVar: undefined + }); +} + function generateBearerAuth({ docs, rawScheme, diff --git a/packages/cli/generation/ir-generator/src/index.ts b/packages/cli/generation/ir-generator/src/index.ts index 658659cafc..1691527174 100644 --- a/packages/cli/generation/ir-generator/src/index.ts +++ b/packages/cli/generation/ir-generator/src/index.ts @@ -17,8 +17,10 @@ export { getPropertyName } from "./converters/type-declarations/convertObjectTyp export * as ExampleValidators from "./examples"; export { constructFernFileContext, constructRootApiFileContext, type FernFileContext } from "./FernFileContext"; export { generateIntermediateRepresentation } from "./generateIntermediateRepresentation"; +export { EndpointResolverImpl, type EndpointResolver } from "./resolvers/EndpointResolver"; export { ErrorResolverImpl, type ErrorResolver } from "./resolvers/ErrorResolver"; export { ExampleResolverImpl, type ExampleResolver } from "./resolvers/ExampleResolver"; +export { type ResolvedEndpoint } from "./resolvers/ResolvedEndpoint"; export { type ResolvedContainerType, type ResolvedType } from "./resolvers/ResolvedType"; export { TypeResolverImpl, type TypeResolver } from "./resolvers/TypeResolver"; export { VariableResolverImpl, type VariableResolver } from "./resolvers/VariableResolver"; @@ -29,4 +31,5 @@ export { type ObjectPropertyWithPath } from "./utils/getAllPropertiesForObject"; export { getResolvedPathOfImportedFile } from "./utils/getResolvedPathOfImportedFile"; +export { parseReferenceToEndpointName, type ReferenceToEndpointName } from "./utils/parseReferenceToEndpointName"; export { parseReferenceToTypeName, type ReferenceToTypeName } from "./utils/parseReferenceToTypeName"; diff --git a/packages/cli/generation/ir-generator/src/resolvers/EndpointResolver.ts b/packages/cli/generation/ir-generator/src/resolvers/EndpointResolver.ts new file mode 100644 index 0000000000..6aa9ab24e1 --- /dev/null +++ b/packages/cli/generation/ir-generator/src/resolvers/EndpointResolver.ts @@ -0,0 +1,89 @@ +import { FernWorkspace, getDefinitionFile } from "@fern-api/workspace-loader"; +import { RawSchemas } from "@fern-api/yaml-schema"; +import { constructFernFileContext, FernFileContext } from "../FernFileContext"; +import { parseReferenceToEndpointName } from "../utils/parseReferenceToEndpointName"; + +export interface EndpointResolver { + resolveEndpoint: (args: { endpoint: string; file: FernFileContext }) => ResolvedEndpoint | undefined; + resolveEndpointOrThrow: (args: { endpoint: string; file: FernFileContext }) => ResolvedEndpoint; +} + +export interface ResolvedEndpoint { + endpointId: string; + endpoint: RawSchemas.HttpEndpointSchema; +} + +interface RawEndpointInfo { + endpointName: string; + file: FernFileContext; +} + +export class EndpointResolverImpl implements EndpointResolver { + constructor(private readonly workspace: FernWorkspace) {} + + public resolveEndpointOrThrow({ endpoint, file }: { endpoint: string; file: FernFileContext }): ResolvedEndpoint { + const resolvedEndpoint = this.resolveEndpoint({ endpoint, file }); + if (resolvedEndpoint == null) { + throw new Error("Cannot resolve endpoint: " + endpoint + " in file " + file.relativeFilepath); + } + return resolvedEndpoint; + } + + public resolveEndpoint({ + endpoint, + file + }: { + endpoint: string; + file: FernFileContext; + }): ResolvedEndpoint | undefined { + const maybeDeclaration = this.getDeclarationOfEndpoint({ + referenceToEndpoint: endpoint, + file + }); + if (maybeDeclaration == null) { + return undefined; + } + const maybeEndpoint = maybeDeclaration.file.definitionFile.service?.endpoints?.[maybeDeclaration.endpointName]; + if (maybeEndpoint == null) { + return undefined; + } + return { + endpointId: maybeDeclaration.endpointName, + endpoint: maybeEndpoint + }; + } + + public getDeclarationOfEndpoint({ + referenceToEndpoint, + file + }: { + referenceToEndpoint: string; + file: FernFileContext; + }): RawEndpointInfo | undefined { + const parsedReference = parseReferenceToEndpointName({ + reference: referenceToEndpoint, + referencedIn: file.relativeFilepath, + imports: file.imports + }); + if (parsedReference == null) { + return undefined; + } + const definitionFile = getDefinitionFile(this.workspace, parsedReference.relativeFilepath); + if (definitionFile == null) { + return undefined; + } + const declaration = definitionFile.service?.endpoints?.[parsedReference.endpointName]; + if (declaration == null) { + return undefined; + } + return { + endpointName: parsedReference.endpointName, + file: constructFernFileContext({ + relativeFilepath: parsedReference.relativeFilepath, + definitionFile, + casingsGenerator: file.casingsGenerator, + rootApiFile: this.workspace.definition.rootApiFile.contents + }) + }; + } +} diff --git a/packages/cli/generation/ir-generator/src/resolvers/ResolvedEndpoint.ts b/packages/cli/generation/ir-generator/src/resolvers/ResolvedEndpoint.ts new file mode 100644 index 0000000000..4324ab011c --- /dev/null +++ b/packages/cli/generation/ir-generator/src/resolvers/ResolvedEndpoint.ts @@ -0,0 +1,6 @@ +import { RawSchemas } from "@fern-api/yaml-schema"; + +export interface ResolvedEndpoint { + endpointId: string; + endpoint: RawSchemas.HttpEndpointSchema; +} diff --git a/packages/cli/generation/ir-generator/src/utils/parseReferenceToEndpointName.ts b/packages/cli/generation/ir-generator/src/utils/parseReferenceToEndpointName.ts new file mode 100644 index 0000000000..22ac255d14 --- /dev/null +++ b/packages/cli/generation/ir-generator/src/utils/parseReferenceToEndpointName.ts @@ -0,0 +1,41 @@ +import { RelativeFilePath } from "@fern-api/fs-utils"; +import { getResolvedPathOfImportedFile } from "./getResolvedPathOfImportedFile"; + +export interface ReferenceToEndpointName { + endpointName: string; + relativeFilepath: RelativeFilePath; +} + +export function parseReferenceToEndpointName({ + reference, + referencedIn, + imports +}: { + reference: string; + referencedIn: RelativeFilePath; + imports: Record; +}): ReferenceToEndpointName | undefined { + const [firstPart, secondPart, ...rest] = reference.split("."); + + if (firstPart == null || rest.length > 0) { + return undefined; + } + + if (secondPart == null) { + return { + endpointName: firstPart, + relativeFilepath: referencedIn + }; + } + + const importAlias = firstPart; + const importPath = imports[importAlias]; + if (importPath == null) { + return undefined; + } + + return { + endpointName: secondPart, + relativeFilepath: getResolvedPathOfImportedFile({ referencedIn, importPath }) + }; +} diff --git a/packages/cli/yaml/validator/src/getAllRules.ts b/packages/cli/yaml/validator/src/getAllRules.ts index 02c40526fd..3792ef876a 100644 --- a/packages/cli/yaml/validator/src/getAllRules.ts +++ b/packages/cli/yaml/validator/src/getAllRules.ts @@ -32,6 +32,7 @@ import { ValidExampleEndpointCallRule } from "./rules/valid-example-endpoint-cal import { ValidExampleTypeRule } from "./rules/valid-example-type"; import { ValidFieldNamesRule } from "./rules/valid-field-names"; import { ValidNavigationRule } from "./rules/valid-navigation"; +import { ValidOauthRule } from "./rules/valid-oauth"; import { ValidPaginationRule } from "./rules/valid-pagination"; import { ValidServiceUrlsRule } from "./rules/valid-service-urls"; import { ValidTypeNameRule } from "./rules/valid-type-name"; @@ -73,6 +74,7 @@ export function getAllRules(): Rule[] { OnlyObjectExtensionsRule, NoMaybeStreamingRule, NoResponsePropertyRule, + ValidOauthRule, ValidPaginationRule ]; } diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/invalid/definition/api.yml b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/invalid/definition/api.yml new file mode 100644 index 0000000000..582ecd8f59 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/invalid/definition/api.yml @@ -0,0 +1,22 @@ +name: invalid +imports: + auth: auth.yml + +auth: OAuthScheme +auth-schemes: + OAuthScheme: + scheme: oauth + type: client-credentials + get-token: + endpoint: auth.getTokenWithClientCredentials + response-properties: + access-token: $response.missing.access_token + expires-in: $response.missing.expires_in + refresh-token: + endpoint: auth.refreshToken + request-properties: + refresh-token: $request.refreshTokenDoesNotExist + response-properties: + access-token: $response.accessTokenDoesNotExist + expires-in: $response.expiresInDoesNotExist + refresh-token: $response.refreshTokenDoesNotExist \ No newline at end of file diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/invalid/definition/auth.yml b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/invalid/definition/auth.yml new file mode 100644 index 0000000000..e52e3cee17 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/invalid/definition/auth.yml @@ -0,0 +1,41 @@ +types: + TokenResponse: + docs: | + An OAuth token response. + properties: + access_token: string + expires_in: integer + refresh_token: optional + +service: + auth: false + base-path: / + endpoints: + getTokenWithClientCredentials: + path: /token + method: POST + request: + name: GetTokenRequest + body: + properties: + client_id: string + client_secret: string + audience: literal<"https://api.example.com"> + grant_type: literal<"client_credentials"> + scope: optional + response: TokenResponse + + refreshToken: + path: /token + method: POST + request: + name: RefreshTokenRequest + body: + properties: + client_id: string + client_secret: string + refresh_token: string + audience: literal<"https://api.example.com"> + grant_type: literal<"refresh_token"> + scope: optional + response: TokenResponse \ No newline at end of file diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/definition/api.yml b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/definition/api.yml new file mode 100644 index 0000000000..ed7324af30 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/definition/api.yml @@ -0,0 +1,22 @@ +name: valid +imports: + auth: auth.yml + +auth: OAuthScheme +auth-schemes: + OAuthScheme: + scheme: oauth + type: client-credentials + get-token: + endpoint: auth.getTokenWithClientCredentials + response-properties: + access-token: $response.access_token + expires-in: $response.expires_in + refresh-token: + endpoint: auth.refreshToken + request-properties: + refresh-token: $request.refresh_token + response-properties: + access-token: $response.access_token + expires-in: $response.expires_in + refresh-token: $response.refresh_token \ No newline at end of file diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/definition/auth.yml b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/definition/auth.yml new file mode 100644 index 0000000000..e52e3cee17 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/definition/auth.yml @@ -0,0 +1,41 @@ +types: + TokenResponse: + docs: | + An OAuth token response. + properties: + access_token: string + expires_in: integer + refresh_token: optional + +service: + auth: false + base-path: / + endpoints: + getTokenWithClientCredentials: + path: /token + method: POST + request: + name: GetTokenRequest + body: + properties: + client_id: string + client_secret: string + audience: literal<"https://api.example.com"> + grant_type: literal<"client_credentials"> + scope: optional + response: TokenResponse + + refreshToken: + path: /token + method: POST + request: + name: RefreshTokenRequest + body: + properties: + client_id: string + client_secret: string + refresh_token: string + audience: literal<"https://api.example.com"> + grant_type: literal<"refresh_token"> + scope: optional + response: TokenResponse \ No newline at end of file diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/generators.yml b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/generators.yml new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/fixtures/valid/generators.yml @@ -0,0 +1 @@ +{} diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/valid-oauth.test.ts b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/valid-oauth.test.ts new file mode 100644 index 0000000000..386a8530d0 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/__test__/valid-oauth.test.ts @@ -0,0 +1,74 @@ +import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; +import { getViolationsForRule } from "../../../testing-utils/getViolationsForRule"; +import { ValidationViolation } from "../../../ValidationViolation"; +import { ValidOauthRule } from "../valid-oauth"; + +describe("valid-oauth", () => { + it("valid", async () => { + const violations = await getViolationsForRule({ + rule: ValidOauthRule, + absolutePathToWorkspace: join( + AbsoluteFilePath.of(__dirname), + RelativeFilePath.of("fixtures"), + RelativeFilePath.of("valid") + ) + }); + expect(violations).toEqual([]); + }); + + it("invalid", async () => { + const violations = await getViolationsForRule({ + rule: ValidOauthRule, + absolutePathToWorkspace: join( + AbsoluteFilePath.of(__dirname), + RelativeFilePath.of("fixtures"), + RelativeFilePath.of("invalid") + ) + }); + const expectedViolations: ValidationViolation[] = [ + { + message: + "OAuth configuration for endpoint getTokenWithClientCredentials specifies 'access-token' $response.missing.access_token, which is not a valid 'access-token' type.", + nodePath: ["service", "endpoints", "getTokenWithClientCredentials"], + relativeFilepath: RelativeFilePath.of("auth.yml"), + severity: "error" + }, + { + message: + "OAuth configuration for endpoint getTokenWithClientCredentials specifies 'expires-in' $response.missing.expires_in, which is not a valid 'expires-in' type.", + nodePath: ["service", "endpoints", "getTokenWithClientCredentials"], + relativeFilepath: RelativeFilePath.of("auth.yml"), + severity: "error" + }, + { + message: + "OAuth configuration for endpoint refreshToken specifies 'refresh-token' $request.refreshTokenDoesNotExist, which is not a valid 'refresh-token' type.", + nodePath: ["service", "endpoints", "refreshToken"], + relativeFilepath: RelativeFilePath.of("auth.yml"), + severity: "error" + }, + { + message: + "OAuth configuration for endpoint refreshToken specifies 'access-token' $response.accessTokenDoesNotExist, which is not a valid 'access-token' type.", + nodePath: ["service", "endpoints", "refreshToken"], + relativeFilepath: RelativeFilePath.of("auth.yml"), + severity: "error" + }, + { + message: + "OAuth configuration for endpoint refreshToken specifies 'expires-in' $response.expiresInDoesNotExist, which is not a valid 'expires-in' type.", + nodePath: ["service", "endpoints", "refreshToken"], + relativeFilepath: RelativeFilePath.of("auth.yml"), + severity: "error" + }, + { + message: + "OAuth configuration for endpoint refreshToken specifies 'refresh-token' $response.refreshTokenDoesNotExist, which is not a valid 'refresh-token' type.", + nodePath: ["service", "endpoints", "refreshToken"], + relativeFilepath: RelativeFilePath.of("auth.yml"), + severity: "error" + } + ]; + expect(violations).toEqual(expectedViolations); + }); +}); diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/index.ts b/packages/cli/yaml/validator/src/rules/valid-oauth/index.ts new file mode 100644 index 0000000000..b01f2bd3f3 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/index.ts @@ -0,0 +1 @@ +export { ValidOauthRule } from "./valid-oauth"; diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts b/packages/cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts new file mode 100644 index 0000000000..b8a3aca117 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts @@ -0,0 +1,153 @@ +import { assertNever } from "@fern-api/core-utils"; +import { + constructFernFileContext, + constructRootApiFileContext, + EndpointResolverImpl, + TypeResolverImpl +} from "@fern-api/ir-generator"; +import { FernWorkspace } from "@fern-api/workspace-loader"; +import { RawSchemas } from "@fern-api/yaml-schema"; +import { Rule } from "../../Rule"; +import { CASINGS_GENERATOR } from "../../utils/casingsGenerator"; +import { validateClientCredentials } from "./validateClientCredentials"; + +export const ValidOauthRule: Rule = { + name: "valid-oauth", + create: ({ workspace }) => { + const oauthScheme = maybeGetOAuthScheme({ workspace }); + if (oauthScheme == null) { + return {}; + } + + const oauthSchema = oauthScheme.schema; + const typeResolver = new TypeResolverImpl(workspace); + const endpointResolver = new EndpointResolverImpl(workspace); + + const imports = workspace.definition.rootApiFile.contents.imports; + if (imports == null) { + return { + rootApiFile: { + file: (_) => { + return [ + { + severity: "error", + message: `File declares oauth scheme '${oauthScheme.name}', but no imports are declared to reference the required token endpoint(s).` + } + ]; + } + } + }; + } + + const apiFile = constructRootApiFileContext({ + casingsGenerator: CASINGS_GENERATOR, + rootApiFile: workspace.definition.rootApiFile.contents + }); + + const resolvedTokenEndpoint = endpointResolver.resolveEndpoint({ + endpoint: oauthSchema["get-token"].endpoint, + file: apiFile + }); + if (resolvedTokenEndpoint == null) { + return { + rootApiFile: { + file: (_) => { + return [ + { + severity: "error", + message: `File declares oauth scheme '${oauthScheme.name}', but the OAuth 'get-token' endpoint could not be resolved.` + } + ]; + } + } + }; + } + + const resolvedRefreshEndpoint = + oauthSchema["refresh-token"] != null + ? endpointResolver.resolveEndpoint({ + endpoint: oauthSchema["refresh-token"].endpoint, + file: apiFile + }) + : undefined; + if (oauthSchema["refresh-token"] != null && resolvedRefreshEndpoint == null) { + return { + rootApiFile: { + file: (_) => { + return [ + { + severity: "error", + message: `File declares oauth scheme '${oauthScheme.name}', but the OAuth 'refresh-token' endpoint could not be resolved.` + } + ]; + } + } + }; + } + + // TODO: Add the default request-properties and response-properties if not set. + + return { + definitionFile: { + httpEndpoint: ({ endpointId, endpoint }, { relativeFilepath, contents: definitionFile }) => { + if ( + endpointId !== resolvedTokenEndpoint.endpointId && + endpointId !== resolvedRefreshEndpoint?.endpointId + ) { + return []; + } + + const file = constructFernFileContext({ + relativeFilepath, + definitionFile, + casingsGenerator: CASINGS_GENERATOR, + rootApiFile: workspace.definition.rootApiFile.contents + }); + + switch (oauthSchema.type) { + case "client-credentials": + return validateClientCredentials({ + endpointId, + endpoint, + typeResolver, + file, + resolvedTokenEndpoint, + resolvedRefreshEndpoint, + clientCredentials: oauthSchema + }); + default: + assertNever(oauthSchema.type); + } + } + } + }; + } +}; + +interface OAuthScheme { + name: string; + schema: RawSchemas.OAuthSchemeSchema; +} + +function maybeGetOAuthScheme({ workspace }: { workspace: FernWorkspace }): OAuthScheme | undefined { + const authSchemes = workspace.definition.rootApiFile.contents["auth-schemes"]; + if (authSchemes == null) { + return undefined; + } + const oauthSchemePair = Object.entries(authSchemes).find(([_, value]) => isRawOAuthSchemeSchema(value)); + if (oauthSchemePair == null) { + return undefined; + } + return { + name: oauthSchemePair[0], + schema: oauthSchemePair[1] as RawSchemas.OAuthSchemeSchema + }; +} + +function isRawOAuthSchemeSchema(rawOAuthSchemeSchema: any): rawOAuthSchemeSchema is RawSchemas.OAuthSchemeSchema { + return ( + (rawOAuthSchemeSchema as RawSchemas.OAuthSchemeSchema).scheme === "oauth" && + (rawOAuthSchemeSchema as RawSchemas.OAuthSchemeSchema).type != null && + (rawOAuthSchemeSchema as RawSchemas.OAuthSchemeSchema)["get-token"] != null + ); +} diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/validateClientCredentials.ts b/packages/cli/yaml/validator/src/rules/valid-oauth/validateClientCredentials.ts new file mode 100644 index 0000000000..bfa382e99d --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/validateClientCredentials.ts @@ -0,0 +1,51 @@ +import { FernFileContext, ResolvedEndpoint, TypeResolver } from "@fern-api/ir-generator"; +import { RawSchemas } from "@fern-api/yaml-schema"; +import { RuleViolation } from "../../Rule"; +import { validateRefreshTokenEndpoint } from "./validateRefreshTokenEndpoint"; +import { validateTokenEndpoint } from "./validateTokenEndpoint"; + +export function validateClientCredentials({ + endpointId, + endpoint, + typeResolver, + file, + resolvedTokenEndpoint, + resolvedRefreshEndpoint, + clientCredentials: oauthScheme +}: { + endpointId: string; + endpoint: RawSchemas.HttpEndpointSchema; + typeResolver: TypeResolver; + file: FernFileContext; + resolvedTokenEndpoint: ResolvedEndpoint; + resolvedRefreshEndpoint: ResolvedEndpoint | undefined; + clientCredentials: RawSchemas.OAuthClientCredentialsSchema; +}): RuleViolation[] { + const violations: RuleViolation[] = []; + + if (endpointId === resolvedTokenEndpoint.endpointId) { + violations.push( + ...validateTokenEndpoint({ + endpointId, + endpoint, + typeResolver, + file, + tokenEndpoint: oauthScheme["get-token"] + }) + ); + } + + if (oauthScheme["refresh-token"] != null && endpointId === resolvedRefreshEndpoint?.endpointId) { + violations.push( + ...validateRefreshTokenEndpoint({ + endpointId, + endpoint, + typeResolver, + file, + refreshEndpoint: oauthScheme["refresh-token"] + }) + ); + } + + return violations; +} diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/validateRefreshTokenEndpoint.ts b/packages/cli/yaml/validator/src/rules/valid-oauth/validateRefreshTokenEndpoint.ts new file mode 100644 index 0000000000..398aca0e48 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/validateRefreshTokenEndpoint.ts @@ -0,0 +1,89 @@ +import { FernFileContext, TypeResolver } from "@fern-api/ir-generator"; +import { RawSchemas } from "@fern-api/yaml-schema"; +import chalk from "chalk"; +import { RuleViolation } from "../../Rule"; +import { maybeFileFromResolvedType, resolveResponseType } from "../../utils/propertyValidatorUtils"; +import { + validateAccessTokenResponseProperty, + validateExpiresInResponseProperty, + validateRefreshTokenRequestProperty, + validateRefreshTokenResponseProperty +} from "./validateUtils"; + +export function validateRefreshTokenEndpoint({ + endpointId, + endpoint, + typeResolver, + file, + refreshEndpoint +}: { + endpointId: string; + endpoint: RawSchemas.HttpEndpointSchema; + typeResolver: TypeResolver; + file: FernFileContext; + refreshEndpoint: RawSchemas.OAuthRefreshTokenEndpointSchema; +}): RuleViolation[] { + const violations: RuleViolation[] = []; + + violations.push( + ...validateRefreshTokenRequestProperty({ + endpointId, + endpoint, + typeResolver, + file, + refreshTokenProperty: refreshEndpoint["request-properties"]["refresh-token"] + }) + ); + + const resolvedResponseType = resolveResponseType({ endpoint, typeResolver, file }); + if (resolvedResponseType == null) { + violations.push({ + severity: "error", + message: `OAuth configuration for endpoint ${chalk.bold(endpointId)} must define a response type.` + }); + return violations; + } + + const accessTokenProperty = refreshEndpoint["response-properties"]["access-token"]; + if (accessTokenProperty != null) { + violations.push( + ...validateAccessTokenResponseProperty({ + endpointId, + typeResolver, + file: maybeFileFromResolvedType(resolvedResponseType) ?? file, + resolvedResponseType, + accessTokenProperty + }) + ); + } + + const expiresInProperty = refreshEndpoint["response-properties"]["expires-in"]; + if (expiresInProperty != null) { + violations.push( + ...validateExpiresInResponseProperty({ + endpointId, + typeResolver, + file: maybeFileFromResolvedType(resolvedResponseType) ?? file, + resolvedResponseType, + expiresInProperty + }) + ); + } + + const refreshTokenProperty = refreshEndpoint["response-properties"]["refresh-token"]; + if (refreshTokenProperty != null) { + violations.push( + ...validateRefreshTokenResponseProperty({ + endpointId, + typeResolver, + file: maybeFileFromResolvedType(resolvedResponseType) ?? file, + resolvedResponseType, + refreshTokenProperty + }) + ); + } + + // TODO: Validate the request has 'grant_type: literal<"refresh_token">'. + + return violations; +} diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/validateTokenEndpoint.ts b/packages/cli/yaml/validator/src/rules/valid-oauth/validateTokenEndpoint.ts new file mode 100644 index 0000000000..ddd49c1d83 --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/validateTokenEndpoint.ts @@ -0,0 +1,75 @@ +import { FernFileContext, TypeResolver } from "@fern-api/ir-generator"; +import { RawSchemas } from "@fern-api/yaml-schema"; +import chalk from "chalk"; +import { RuleViolation } from "../../Rule"; +import { maybeFileFromResolvedType, resolveResponseType } from "../../utils/propertyValidatorUtils"; +import { + validateAccessTokenResponseProperty, + validateExpiresInResponseProperty, + validateRefreshTokenResponseProperty +} from "./validateUtils"; + +export function validateTokenEndpoint({ + endpointId, + endpoint, + typeResolver, + file, + tokenEndpoint +}: { + endpointId: string; + endpoint: RawSchemas.HttpEndpointSchema; + typeResolver: TypeResolver; + file: FernFileContext; + tokenEndpoint: RawSchemas.OAuthGetTokenEndpointSchema; +}): RuleViolation[] { + const violations: RuleViolation[] = []; + + const resolvedResponseType = resolveResponseType({ endpoint, typeResolver, file }); + if (resolvedResponseType == null) { + violations.push({ + severity: "error", + message: `OAuth configuration for endpoint ${chalk.bold(endpointId)} must define a response type.` + }); + return violations; + } + + violations.push( + ...validateAccessTokenResponseProperty({ + endpointId, + typeResolver, + file: maybeFileFromResolvedType(resolvedResponseType) ?? file, + resolvedResponseType, + accessTokenProperty: tokenEndpoint["response-properties"]["access-token"] + }) + ); + + const expiresInProperty = tokenEndpoint["response-properties"]["expires-in"]; + if (expiresInProperty != null) { + violations.push( + ...validateExpiresInResponseProperty({ + endpointId, + typeResolver, + file: maybeFileFromResolvedType(resolvedResponseType) ?? file, + resolvedResponseType, + expiresInProperty + }) + ); + } + + const refreshTokenProperty = tokenEndpoint["response-properties"]["refresh-token"]; + if (refreshTokenProperty != null) { + violations.push( + ...validateRefreshTokenResponseProperty({ + endpointId, + typeResolver, + file: maybeFileFromResolvedType(resolvedResponseType) ?? file, + resolvedResponseType, + refreshTokenProperty + }) + ); + } + + // TODO: Validate the request has 'grant_type: literal<"client_credentials">'. + + return violations; +} diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/validateUtils.ts b/packages/cli/yaml/validator/src/rules/valid-oauth/validateUtils.ts new file mode 100644 index 0000000000..37a2fe7d6f --- /dev/null +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/validateUtils.ts @@ -0,0 +1,239 @@ +import { FernFileContext, ResolvedType, TypeResolver } from "@fern-api/ir-generator"; +import { RawSchemas } from "@fern-api/yaml-schema"; +import chalk from "chalk"; +import { RuleViolation } from "../../Rule"; +import { + getRequestPropertyComponents, + getResponsePropertyComponents, + maybePrimitiveType, + PropertyValidator, + requestTypeHasProperty, + resolvedTypeHasProperty +} from "../../utils/propertyValidatorUtils"; + +export function validateRefreshTokenRequestProperty({ + endpointId, + endpoint, + typeResolver, + file, + refreshTokenProperty +}: { + endpointId: string; + endpoint: RawSchemas.HttpEndpointSchema; + typeResolver: TypeResolver; + file: FernFileContext; + refreshTokenProperty: string; +}): RuleViolation[] { + const violations: RuleViolation[] = []; + + const refreshTokenPropertyComponents = getRequestPropertyComponents(refreshTokenProperty); + if (refreshTokenPropertyComponents == null) { + violations.push({ + severity: "error", + message: `OAuth configuration for endpoint ${chalk.bold( + endpointId + )} must define a dot-delimited 'refresh-token' property starting with $request (e.g. $request.refresh_token).` + }); + return violations; + } + + if ( + !requestTypeHasProperty({ + typeResolver, + file, + endpoint, + propertyComponents: refreshTokenPropertyComponents, + validate: isValidTokenType + }) + ) { + violations.push({ + severity: "error", + message: `OAuth configuration for endpoint ${chalk.bold( + endpointId + )} specifies 'refresh-token' ${refreshTokenProperty}, which is not a valid 'refresh-token' type.` + }); + } + + return violations; +} + +export function validateAccessTokenResponseProperty({ + endpointId, + typeResolver, + file, + resolvedResponseType, + accessTokenProperty +}: { + endpointId: string; + typeResolver: TypeResolver; + file: FernFileContext; + resolvedResponseType: ResolvedType; + accessTokenProperty: string; +}): RuleViolation[] { + return validateResponseProperty({ + endpointId, + typeResolver, + file, + resolvedResponseType, + responseProperty: accessTokenProperty, + propertyValidator: { + propertyID: "access-token", + validate: isValidTokenProperty + } + }); +} + +export function validateRefreshTokenResponseProperty({ + endpointId, + typeResolver, + file, + resolvedResponseType, + refreshTokenProperty +}: { + endpointId: string; + typeResolver: TypeResolver; + file: FernFileContext; + resolvedResponseType: ResolvedType; + refreshTokenProperty: string; +}): RuleViolation[] { + return validateResponseProperty({ + endpointId, + typeResolver, + file, + resolvedResponseType, + responseProperty: refreshTokenProperty, + propertyValidator: { + propertyID: "refresh-token", + validate: isValidTokenProperty + } + }); +} + +export function validateExpiresInResponseProperty({ + endpointId, + typeResolver, + file, + resolvedResponseType, + expiresInProperty +}: { + endpointId: string; + typeResolver: TypeResolver; + file: FernFileContext; + resolvedResponseType: ResolvedType; + expiresInProperty: string; +}): RuleViolation[] { + return validateResponseProperty({ + endpointId, + typeResolver, + file, + resolvedResponseType, + responseProperty: expiresInProperty, + propertyValidator: { + propertyID: "expires-in", + validate: isValidExpiresInProperty + } + }); +} + +function isValidExpiresInProperty({ + typeResolver, + file, + resolvedType, + propertyComponents +}: { + typeResolver: TypeResolver; + file: FernFileContext; + resolvedType: ResolvedType | undefined; + propertyComponents: string[]; +}): boolean { + return resolvedTypeHasProperty({ + typeResolver, + file, + resolvedType, + propertyComponents, + validate: isValidExpiryType + }); +} + +function isValidExpiryType(resolvedType: ResolvedType | undefined): boolean { + const primitiveType = maybePrimitiveType(resolvedType); + if (primitiveType == null) { + return false; + } + return primitiveType === "INTEGER"; +} + +function isValidTokenProperty({ + typeResolver, + file, + resolvedType, + propertyComponents +}: { + typeResolver: TypeResolver; + file: FernFileContext; + resolvedType: ResolvedType | undefined; + propertyComponents: string[]; +}): boolean { + return resolvedTypeHasProperty({ + typeResolver, + file, + resolvedType, + propertyComponents, + validate: isValidTokenType + }); +} + +function isValidTokenType(resolvedType: ResolvedType | undefined): boolean { + const primitiveType = maybePrimitiveType(resolvedType); + if (primitiveType == null) { + return false; + } + return primitiveType === "STRING"; +} + +function validateResponseProperty({ + endpointId, + typeResolver, + file, + resolvedResponseType, + responseProperty, + propertyValidator +}: { + endpointId: string; + typeResolver: TypeResolver; + file: FernFileContext; + resolvedResponseType: ResolvedType; + responseProperty: string; + propertyValidator: PropertyValidator; +}): RuleViolation[] { + const violations: RuleViolation[] = []; + + const responsePropertyComponents = getResponsePropertyComponents(responseProperty); + if (responsePropertyComponents == null) { + violations.push({ + severity: "error", + message: `OAuth configuration for endpoint ${chalk.bold(endpointId)} must define a dot-delimited '${ + propertyValidator.propertyID + }' property starting with $response (e.g. $response.${propertyValidator.propertyID}).` + }); + } + + if ( + responsePropertyComponents != null && + !propertyValidator.validate({ + typeResolver, + file, + resolvedType: resolvedResponseType, + propertyComponents: responsePropertyComponents + }) + ) { + violations.push({ + severity: "error", + message: `OAuth configuration for endpoint ${chalk.bold(endpointId)} specifies '${ + propertyValidator.propertyID + }' ${responseProperty}, which is not a valid '${propertyValidator.propertyID}' type.` + }); + } + + return violations; +} diff --git a/packages/cli/yaml/validator/src/rules/valid-pagination/validateCursorPagination.ts b/packages/cli/yaml/validator/src/rules/valid-pagination/validateCursorPagination.ts index 7dbc3cf06d..3398a81238 100644 --- a/packages/cli/yaml/validator/src/rules/valid-pagination/validateCursorPagination.ts +++ b/packages/cli/yaml/validator/src/rules/valid-pagination/validateCursorPagination.ts @@ -6,11 +6,9 @@ import { maybeFileFromResolvedType, maybePrimitiveType, resolvedTypeHasProperty, - resolveResponseType, - validateQueryParameterProperty, - validateResponseProperty, - validateResultsProperty -} from "./validateUtils"; + resolveResponseType +} from "../../utils/propertyValidatorUtils"; +import { validateQueryParameterProperty, validateResponseProperty, validateResultsProperty } from "./validateUtils"; export function validateCursorPagination({ endpointId, diff --git a/packages/cli/yaml/validator/src/rules/valid-pagination/validateOffsetPagination.ts b/packages/cli/yaml/validator/src/rules/valid-pagination/validateOffsetPagination.ts index 7929b0087b..a5cdb66944 100644 --- a/packages/cli/yaml/validator/src/rules/valid-pagination/validateOffsetPagination.ts +++ b/packages/cli/yaml/validator/src/rules/valid-pagination/validateOffsetPagination.ts @@ -6,10 +6,9 @@ import { maybeFileFromResolvedType, maybePrimitiveType, resolvedTypeHasProperty, - resolveResponseType, - validateQueryParameterProperty, - validateResultsProperty -} from "./validateUtils"; + resolveResponseType +} from "../../utils/propertyValidatorUtils"; +import { validateQueryParameterProperty, validateResultsProperty } from "./validateUtils"; export function validateOffsetPagination({ endpointId, diff --git a/packages/cli/yaml/validator/src/rules/valid-pagination/validateUtils.ts b/packages/cli/yaml/validator/src/rules/valid-pagination/validateUtils.ts index 8f19434dc1..094295d238 100644 --- a/packages/cli/yaml/validator/src/rules/valid-pagination/validateUtils.ts +++ b/packages/cli/yaml/validator/src/rules/valid-pagination/validateUtils.ts @@ -1,27 +1,14 @@ import { FernFileContext, ResolvedType, TypeResolver } from "@fern-api/ir-generator"; -import { isRawObjectDefinition, RawSchemas } from "@fern-api/yaml-schema"; +import { RawSchemas } from "@fern-api/yaml-schema"; import chalk from "chalk"; import { RuleViolation } from "../../Rule"; - -const REQUEST_PREFIX = "$request."; -const RESPONSE_PREFIX = "$response."; - -export interface PropertyValidator { - propertyID: string; - validate: PropertyValidatorFunc; -} - -export type PropertyValidatorFunc = ({ - typeResolver, - file, - resolvedType, - propertyComponents -}: { - typeResolver: TypeResolver; - file: FernFileContext; - resolvedType: ResolvedType | undefined; - propertyComponents: string[]; -}) => boolean; +import { + getRequestPropertyComponents, + getResponsePropertyComponents, + maybeFileFromResolvedType, + PropertyValidator, + resolvedTypeHasProperty, +} from "../../utils/propertyValidatorUtils"; export function validateResultsProperty({ endpointId, @@ -181,169 +168,6 @@ export function validateResponseProperty({ return violations; } -export function resolvedTypeHasProperty({ - typeResolver, - file, - resolvedType, - propertyComponents, - validate -}: { - typeResolver: TypeResolver; - file: FernFileContext; - resolvedType: ResolvedType | undefined; - propertyComponents: string[]; - validate: (resolvedType: ResolvedType | undefined) => boolean; -}): boolean { - if (propertyComponents.length === 0) { - return validate(resolvedType); - } - const objectSchema = maybeObjectSchema(resolvedType); - if (objectSchema == null) { - return false; - } - const property = getPropertyTypeFromObjectSchema({ - typeResolver, - file, - objectSchema, - property: propertyComponents[0] ?? "" - }); - if (property == null) { - return false; - } - const resolvedTypeProperty = typeResolver.resolveType({ - type: property, - file - }); - return resolvedTypeHasProperty({ - typeResolver, - file: maybeFileFromResolvedType(resolvedTypeProperty) ?? file, - resolvedType: resolvedTypeProperty, - propertyComponents: propertyComponents.slice(1), - validate - }); -} - -export function resolveResponseType({ - endpoint, - typeResolver, - file -}: { - endpoint: RawSchemas.HttpEndpointSchema; - typeResolver: TypeResolver; - file: FernFileContext; -}): ResolvedType | undefined { - const responseType = typeof endpoint.response !== "string" ? endpoint.response?.type : endpoint.response; - if (responseType == null) { - return undefined; - } - return typeResolver.resolveType({ - type: responseType, - file - }); -} - -export function maybePrimitiveType(resolvedType: ResolvedType | undefined): string | undefined { - if (resolvedType?._type === "primitive") { - return resolvedType.primitive; - } - if (resolvedType?._type === "container" && resolvedType.container._type === "optional") { - return maybePrimitiveType(resolvedType.container.itemType); - } - return undefined; -} - -export function maybeFileFromResolvedType(resolvedType: ResolvedType | undefined): FernFileContext | undefined { - if (resolvedType == null) { - return undefined; - } - if (resolvedType._type === "named") { - return resolvedType.file; - } - if (resolvedType._type === "container" && resolvedType.container._type === "optional") { - return maybeFileFromResolvedType(resolvedType.container.itemType); - } - return undefined; -} - -function getPropertyTypeFromObjectSchema({ - typeResolver, - file, - objectSchema, - property -}: { - typeResolver: TypeResolver; - file: FernFileContext; - objectSchema: RawSchemas.ObjectSchema; - property: string; -}): string | undefined { - const properties = getAllPropertiesForRawObjectSchema({ - typeResolver, - file, - objectSchema - }); - return properties[property]; -} - -function getAllPropertiesForRawObjectSchema({ - typeResolver, - file, - objectSchema -}: { - typeResolver: TypeResolver; - file: FernFileContext; - objectSchema: RawSchemas.ObjectSchema; -}): Record { - let extendedTypes: string[] = []; - if (typeof objectSchema.extends === "string") { - extendedTypes = [objectSchema.extends]; - } else if (Array.isArray(objectSchema.extends)) { - extendedTypes = objectSchema.extends; - } - - const properties: Record = {}; - for (const extendedType of extendedTypes) { - const extendedProperties = getAllPropertiesForExtendedType({ - typeResolver, - file, - extendedType - }); - Object.entries(extendedProperties).map(([propertyKey, propertyType]) => { - properties[propertyKey] = propertyType; - }); - } - - if (objectSchema.properties != null) { - Object.entries(objectSchema.properties).map(([propertyKey, propertyType]) => { - properties[propertyKey] = typeof propertyType === "string" ? propertyType : propertyType.type; - }); - } - - return properties; -} - -function getAllPropertiesForExtendedType({ - typeResolver, - file, - extendedType -}: { - typeResolver: TypeResolver; - file: FernFileContext; - extendedType: string; -}): Record { - const resolvedType = typeResolver.resolveNamedTypeOrThrow({ - referenceToNamedType: extendedType, - file - }); - if (resolvedType._type === "named" && isRawObjectDefinition(resolvedType.declaration)) { - return getAllPropertiesForRawObjectSchema({ - typeResolver, - file: maybeFileFromResolvedType(resolvedType) ?? file, - objectSchema: resolvedType.declaration - }); - } - return {}; -} - function isValidResultsProperty({ typeResolver, file, @@ -367,33 +191,3 @@ function isValidResultsProperty({ function isValidResultsType(resolvedType: ResolvedType | undefined): boolean { return true; } - -function maybeObjectSchema(resolvedType: ResolvedType | undefined): RawSchemas.ObjectSchema | undefined { - if (resolvedType == null) { - return undefined; - } - if (resolvedType._type === "named" && isRawObjectDefinition(resolvedType.declaration)) { - return resolvedType.declaration; - } - if (resolvedType._type === "container" && resolvedType.container._type === "optional") { - return maybeObjectSchema(resolvedType.container.itemType); - } - return undefined; -} - -function getRequestPropertyComponents(value: string): string[] | undefined { - const trimmed = trimPrefix(value, REQUEST_PREFIX); - return trimmed?.split("."); -} - -function getResponsePropertyComponents(value: string): string[] | undefined { - const trimmed = trimPrefix(value, RESPONSE_PREFIX); - return trimmed?.split("."); -} - -function trimPrefix(value: string, prefix: string): string | null { - if (value.startsWith(prefix)) { - return value.substring(prefix.length); - } - return null; -} diff --git a/packages/cli/yaml/validator/src/utils/propertyValidatorUtils.ts b/packages/cli/yaml/validator/src/utils/propertyValidatorUtils.ts new file mode 100644 index 0000000000..8c68b71425 --- /dev/null +++ b/packages/cli/yaml/validator/src/utils/propertyValidatorUtils.ts @@ -0,0 +1,323 @@ +import { FernFileContext, ResolvedType, TypeResolver } from "@fern-api/ir-generator"; +import { isInlineRequestBody, isRawObjectDefinition, RawSchemas } from "@fern-api/yaml-schema"; + +const REQUEST_PREFIX = "$request."; +const RESPONSE_PREFIX = "$response."; + +export interface PropertyValidator { + propertyID: string; + validate: PropertyValidatorFunc; +} + +export type PropertyValidatorFunc = ({ + typeResolver, + file, + resolvedType, + propertyComponents +}: { + typeResolver: TypeResolver; + file: FernFileContext; + resolvedType: ResolvedType | undefined; + propertyComponents: string[]; +}) => boolean; + +export function requestTypeHasProperty({ + typeResolver, + file, + endpoint, + propertyComponents, + validate +}: { + typeResolver: TypeResolver; + file: FernFileContext; + endpoint: RawSchemas.HttpEndpointSchema; + propertyComponents: string[]; + validate: (resolvedType: ResolvedType | undefined) => boolean; +}): boolean { + if (endpoint.request == null) { + return false; + } + if (typeof endpoint.request === "string") { + return resolvedTypeHasProperty({ + typeResolver, + file, + resolvedType: typeResolver.resolveType({ + type: endpoint.request, + file + }), + propertyComponents, + validate + }); + } + return inlinedRequestTypeHasProperty({ + typeResolver, + file, + requestType: endpoint.request, + propertyComponents, + validate + }); +} + +function inlinedRequestTypeHasProperty({ + typeResolver, + file, + requestType, + propertyComponents, + validate +}: { + typeResolver: TypeResolver; + file: FernFileContext; + requestType: RawSchemas.HttpRequestSchema; + propertyComponents: string[]; + validate: (resolvedType: ResolvedType | undefined) => boolean; +}): boolean { + if (requestType.body == null) { + return false; + } + if (typeof requestType.body === "string") { + return resolvedTypeHasProperty({ + typeResolver, + file, + resolvedType: typeResolver.resolveType({ + type: requestType.body, + file + }), + propertyComponents, + validate + }); + } + if (isInlineRequestBody(requestType.body)) { + return objectSchemaHasProperty({ + typeResolver, + file, + objectSchema: requestType.body, + propertyComponents, + validate + }); + } + return resolvedTypeHasProperty({ + typeResolver, + file, + resolvedType: typeResolver.resolveType({ + type: requestType.body.type, + file + }), + propertyComponents, + validate + }); +} + +export function resolvedTypeHasProperty({ + typeResolver, + file, + resolvedType, + propertyComponents, + validate +}: { + typeResolver: TypeResolver; + file: FernFileContext; + resolvedType: ResolvedType | undefined; + propertyComponents: string[]; + validate: (resolvedType: ResolvedType | undefined) => boolean; +}): boolean { + if (propertyComponents.length === 0) { + return validate(resolvedType); + } + const objectSchema = maybeObjectSchema(resolvedType); + if (objectSchema == null) { + return false; + } + return objectSchemaHasProperty({ + typeResolver, + file, + objectSchema, + propertyComponents, + validate + }); +} + +export function resolveResponseType({ + endpoint, + typeResolver, + file +}: { + endpoint: RawSchemas.HttpEndpointSchema; + typeResolver: TypeResolver; + file: FernFileContext; +}): ResolvedType | undefined { + const responseType = typeof endpoint.response !== "string" ? endpoint.response?.type : endpoint.response; + if (responseType == null) { + return undefined; + } + return typeResolver.resolveType({ + type: responseType, + file + }); +} + +export function maybePrimitiveType(resolvedType: ResolvedType | undefined): string | undefined { + if (resolvedType?._type === "primitive") { + return resolvedType.primitive; + } + if (resolvedType?._type === "container" && resolvedType.container._type === "optional") { + return maybePrimitiveType(resolvedType.container.itemType); + } + return undefined; +} + +export function maybeFileFromResolvedType(resolvedType: ResolvedType | undefined): FernFileContext | undefined { + if (resolvedType == null) { + return undefined; + } + if (resolvedType._type === "named") { + return resolvedType.file; + } + if (resolvedType._type === "container" && resolvedType.container._type === "optional") { + return maybeFileFromResolvedType(resolvedType.container.itemType); + } + return undefined; +} + +export function getRequestPropertyComponents(value: string): string[] | undefined { + const trimmed = trimPrefix(value, REQUEST_PREFIX); + return trimmed?.split("."); +} + +export function getResponsePropertyComponents(value: string): string[] | undefined { + const trimmed = trimPrefix(value, RESPONSE_PREFIX); + return trimmed?.split("."); +} + +function objectSchemaHasProperty({ + typeResolver, + file, + objectSchema, + propertyComponents, + validate +}: { + typeResolver: TypeResolver; + file: FernFileContext; + objectSchema: RawSchemas.ObjectSchema; + propertyComponents: string[]; + validate: (resolvedType: ResolvedType | undefined) => boolean; +}): boolean { + const property = getPropertyTypeFromObjectSchema({ + typeResolver, + file, + objectSchema, + property: propertyComponents[0] ?? "" + }); + if (property == null) { + return false; + } + const resolvedTypeProperty = typeResolver.resolveType({ + type: property, + file + }); + return resolvedTypeHasProperty({ + typeResolver, + file: maybeFileFromResolvedType(resolvedTypeProperty) ?? file, + resolvedType: resolvedTypeProperty, + propertyComponents: propertyComponents.slice(1), + validate + }); +} + +function getPropertyTypeFromObjectSchema({ + typeResolver, + file, + objectSchema, + property +}: { + typeResolver: TypeResolver; + file: FernFileContext; + objectSchema: RawSchemas.ObjectSchema; + property: string; +}): string | undefined { + const properties = getAllPropertiesForRawObjectSchema({ + typeResolver, + file, + objectSchema + }); + return properties[property]; +} + +function getAllPropertiesForRawObjectSchema({ + typeResolver, + file, + objectSchema +}: { + typeResolver: TypeResolver; + file: FernFileContext; + objectSchema: RawSchemas.ObjectSchema; +}): Record { + let extendedTypes: string[] = []; + if (typeof objectSchema.extends === "string") { + extendedTypes = [objectSchema.extends]; + } else if (Array.isArray(objectSchema.extends)) { + extendedTypes = objectSchema.extends; + } + + const properties: Record = {}; + for (const extendedType of extendedTypes) { + const extendedProperties = getAllPropertiesForExtendedType({ + typeResolver, + file, + extendedType + }); + Object.entries(extendedProperties).map(([propertyKey, propertyType]) => { + properties[propertyKey] = propertyType; + }); + } + + if (objectSchema.properties != null) { + Object.entries(objectSchema.properties).map(([propertyKey, propertyType]) => { + properties[propertyKey] = typeof propertyType === "string" ? propertyType : propertyType.type; + }); + } + + return properties; +} + +function getAllPropertiesForExtendedType({ + typeResolver, + file, + extendedType +}: { + typeResolver: TypeResolver; + file: FernFileContext; + extendedType: string; +}): Record { + const resolvedType = typeResolver.resolveNamedTypeOrThrow({ + referenceToNamedType: extendedType, + file + }); + if (resolvedType._type === "named" && isRawObjectDefinition(resolvedType.declaration)) { + return getAllPropertiesForRawObjectSchema({ + typeResolver, + file: maybeFileFromResolvedType(resolvedType) ?? file, + objectSchema: resolvedType.declaration + }); + } + return {}; +} + +function maybeObjectSchema(resolvedType: ResolvedType | undefined): RawSchemas.ObjectSchema | undefined { + if (resolvedType == null) { + return undefined; + } + if (resolvedType._type === "named" && isRawObjectDefinition(resolvedType.declaration)) { + return resolvedType.declaration; + } + if (resolvedType._type === "container" && resolvedType.container._type === "optional") { + return maybeObjectSchema(resolvedType.container.itemType); + } + return undefined; +} + +function trimPrefix(value: string, prefix: string): string | null { + if (value.startsWith(prefix)) { + return value.substring(prefix.length); + } + return null; +} diff --git a/packages/cli/yaml/yaml-schema/src/schemas/AuthSchemeDeclarationSchema.ts b/packages/cli/yaml/yaml-schema/src/schemas/AuthSchemeDeclarationSchema.ts index 4afbde277c..2ca449a638 100644 --- a/packages/cli/yaml/yaml-schema/src/schemas/AuthSchemeDeclarationSchema.ts +++ b/packages/cli/yaml/yaml-schema/src/schemas/AuthSchemeDeclarationSchema.ts @@ -2,8 +2,10 @@ import { z } from "zod"; import { BasicAuthSchemeSchema } from "./BasicAuthSchemeSchema"; import { BearerAuthSchemeSchema } from "./BearerAuthSchemeSchema"; import { HeaderAuthSchemeSchema } from "./HeaderAuthSchemeSchema"; +import { OAuthSchemeSchema } from "./OAuthSchemeSchema"; export const AuthSchemeDeclarationSchema = z.union([ + OAuthSchemeSchema, HeaderAuthSchemeSchema, BasicAuthSchemeSchema, BearerAuthSchemeSchema diff --git a/packages/cli/yaml/yaml-schema/src/schemas/OAuthAccessTokenPropertiesSchema.ts b/packages/cli/yaml/yaml-schema/src/schemas/OAuthAccessTokenPropertiesSchema.ts new file mode 100644 index 0000000000..6d72a487ba --- /dev/null +++ b/packages/cli/yaml/yaml-schema/src/schemas/OAuthAccessTokenPropertiesSchema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const OAuthAccessTokenPropertiesSchema = z.strictObject({ + "access-token": z.string(), + "expires-in": z.optional(z.string()), + "refresh-token": z.optional(z.string()) +}); + +export type OAuthAccessTokenPropertiesSchema = z.infer; diff --git a/packages/cli/yaml/yaml-schema/src/schemas/OAuthClientCredentialsSchema.ts b/packages/cli/yaml/yaml-schema/src/schemas/OAuthClientCredentialsSchema.ts new file mode 100644 index 0000000000..d0bbd8671c --- /dev/null +++ b/packages/cli/yaml/yaml-schema/src/schemas/OAuthClientCredentialsSchema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { OAuthGetTokenEndpointSchema } from "./OAuthGetTokenEndpointSchema"; +import { OAuthRefreshTokenEndpointSchema } from "./OAuthRefreshTokenEndpointSchema"; + +export const OAuthClientCredentialsSchema = z.strictObject({ + scheme: z.literal("oauth"), + type: z.literal("client-credentials"), + scopes: z.optional(z.array(z.string())), + "client-id-env": z.optional(z.string()), + "client-secret-env": z.optional(z.string()), + "token-prefix": z.optional(z.string()), + "get-token": OAuthGetTokenEndpointSchema, + "refresh-token": z.optional(OAuthRefreshTokenEndpointSchema) +}); + +export type OAuthClientCredentialsSchema = z.infer; diff --git a/packages/cli/yaml/yaml-schema/src/schemas/OAuthGetTokenEndpointSchema.ts b/packages/cli/yaml/yaml-schema/src/schemas/OAuthGetTokenEndpointSchema.ts new file mode 100644 index 0000000000..3495aa135a --- /dev/null +++ b/packages/cli/yaml/yaml-schema/src/schemas/OAuthGetTokenEndpointSchema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { OAuthAccessTokenPropertiesSchema } from "./OAuthAccessTokenPropertiesSchema"; + +export const OAuthGetTokenEndpointSchema = z.strictObject({ + endpoint: z.string().describe("The endpoint to get the access token, such as 'auth.get_token')"), + "response-properties": OAuthAccessTokenPropertiesSchema +}); + +export type OAuthGetTokenEndpointSchema = z.infer; diff --git a/packages/cli/yaml/yaml-schema/src/schemas/OAuthRefreshTokenEndpointSchema.ts b/packages/cli/yaml/yaml-schema/src/schemas/OAuthRefreshTokenEndpointSchema.ts new file mode 100644 index 0000000000..2053655874 --- /dev/null +++ b/packages/cli/yaml/yaml-schema/src/schemas/OAuthRefreshTokenEndpointSchema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { OAuthAccessTokenPropertiesSchema } from "./OAuthAccessTokenPropertiesSchema"; +import { OAuthRefreshTokenPropertiesSchema } from "./OAuthRefreshTokenPropertiesSchema"; + +export const OAuthRefreshTokenEndpointSchema = z.strictObject({ + endpoint: z.string().describe("The endpoint to refresh the access token, such as 'auth.refresh_token')"), + "request-properties": OAuthRefreshTokenPropertiesSchema, + "response-properties": OAuthAccessTokenPropertiesSchema +}); + +export type OAuthRefreshTokenEndpointSchema = z.infer; diff --git a/packages/cli/yaml/yaml-schema/src/schemas/OAuthRefreshTokenPropertiesSchema.ts b/packages/cli/yaml/yaml-schema/src/schemas/OAuthRefreshTokenPropertiesSchema.ts new file mode 100644 index 0000000000..0a03d6c928 --- /dev/null +++ b/packages/cli/yaml/yaml-schema/src/schemas/OAuthRefreshTokenPropertiesSchema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const OAuthRefreshTokenPropertiesSchema = z.strictObject({ + "refresh-token": z.string() +}); + +export type OAuthRefreshTokenPropertiesSchema = z.infer; diff --git a/packages/cli/yaml/yaml-schema/src/schemas/OAuthSchemeSchema.ts b/packages/cli/yaml/yaml-schema/src/schemas/OAuthSchemeSchema.ts new file mode 100644 index 0000000000..3e1c86feba --- /dev/null +++ b/packages/cli/yaml/yaml-schema/src/schemas/OAuthSchemeSchema.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; +import { OAuthClientCredentialsSchema } from "./OAuthClientCredentialsSchema"; + +export const OAuthSchemeSchema = OAuthClientCredentialsSchema; + +export type OAuthSchemeSchema = z.infer; diff --git a/packages/cli/yaml/yaml-schema/src/schemas/index.ts b/packages/cli/yaml/yaml-schema/src/schemas/index.ts index 52fb007259..2b854f9c66 100644 --- a/packages/cli/yaml/yaml-schema/src/schemas/index.ts +++ b/packages/cli/yaml/yaml-schema/src/schemas/index.ts @@ -39,6 +39,10 @@ export { HttpResponseSchema } from "./HttpResponseSchema"; export { HttpResponseStreamSchema } from "./HttpResponseStreamSchema"; export { HttpServiceSchema } from "./HttpServiceSchema"; export { MultipleBaseUrlsEnvironmentSchema } from "./MultipleBaseUrlsEnvironmentSchema"; +export { OAuthClientCredentialsSchema } from "./OAuthClientCredentialsSchema"; +export { OAuthGetTokenEndpointSchema } from "./OAuthGetTokenEndpointSchema"; +export { OAuthRefreshTokenEndpointSchema } from "./OAuthRefreshTokenEndpointSchema"; +export { OAuthSchemeSchema } from "./OAuthSchemeSchema"; export { ObjectPropertySchema } from "./ObjectPropertySchema"; export { ObjectSchema } from "./ObjectSchema"; export { OffsetPaginationSchema } from "./OffsetPaginationSchema"; diff --git a/packages/cli/yaml/yaml-schema/src/utils/visitRawAuthSchemeDeclaration.ts b/packages/cli/yaml/yaml-schema/src/utils/visitRawAuthSchemeDeclaration.ts index 87167b5702..5da99c9d81 100644 --- a/packages/cli/yaml/yaml-schema/src/utils/visitRawAuthSchemeDeclaration.ts +++ b/packages/cli/yaml/yaml-schema/src/utils/visitRawAuthSchemeDeclaration.ts @@ -5,11 +5,13 @@ import { BearerAuthSchemeSchema, HeaderAuthSchemeSchema } from "../schemas"; +import { OAuthSchemeSchema } from "../schemas/OAuthSchemeSchema"; export interface AuthSchemeDeclarationVisitor { header: (authScheme: HeaderAuthSchemeSchema) => R; basic: (authScheme: BasicAuthSchemeSchema) => R; bearer: (authScheme: BearerAuthSchemeSchema) => R; + oauth: (authScheme: OAuthSchemeSchema) => R; } export function visitRawAuthSchemeDeclaration( @@ -24,6 +26,8 @@ export function visitRawAuthSchemeDeclaration( return visitor.basic(authScheme); case "bearer": return visitor.bearer(authScheme); + case "oauth": + return visitor.oauth(authScheme); default: assertNever(authScheme); } From 3d2a3af51b4fdca25dfa370b52b91ef44f15d345 Mon Sep 17 00:00:00 2001 From: Alex McKinney Date: Thu, 18 Apr 2024 15:25:06 -0700 Subject: [PATCH 2/3] Fix lint/format --- .../cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts | 2 +- .../yaml/validator/src/rules/valid-pagination/validateUtils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts b/packages/cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts index b8a3aca117..98b255084c 100644 --- a/packages/cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts +++ b/packages/cli/yaml/validator/src/rules/valid-oauth/valid-oauth.ts @@ -144,7 +144,7 @@ function maybeGetOAuthScheme({ workspace }: { workspace: FernWorkspace }): OAuth }; } -function isRawOAuthSchemeSchema(rawOAuthSchemeSchema: any): rawOAuthSchemeSchema is RawSchemas.OAuthSchemeSchema { +function isRawOAuthSchemeSchema(rawOAuthSchemeSchema: unknown): rawOAuthSchemeSchema is RawSchemas.OAuthSchemeSchema { return ( (rawOAuthSchemeSchema as RawSchemas.OAuthSchemeSchema).scheme === "oauth" && (rawOAuthSchemeSchema as RawSchemas.OAuthSchemeSchema).type != null && diff --git a/packages/cli/yaml/validator/src/rules/valid-pagination/validateUtils.ts b/packages/cli/yaml/validator/src/rules/valid-pagination/validateUtils.ts index 094295d238..6df3e7d5e5 100644 --- a/packages/cli/yaml/validator/src/rules/valid-pagination/validateUtils.ts +++ b/packages/cli/yaml/validator/src/rules/valid-pagination/validateUtils.ts @@ -7,7 +7,7 @@ import { getResponsePropertyComponents, maybeFileFromResolvedType, PropertyValidator, - resolvedTypeHasProperty, + resolvedTypeHasProperty } from "../../utils/propertyValidatorUtils"; export function validateResultsProperty({ From 458a94d4720f106a6c20ac7863fa444177738e26 Mon Sep 17 00:00:00 2001 From: Alex McKinney Date: Thu, 18 Apr 2024 15:52:05 -0700 Subject: [PATCH 3/3] Add section to api-yml.mdx --- .../define-your-api/ferndef/api-yml.mdx | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/fern/pages/overview/define-your-api/ferndef/api-yml.mdx b/fern/pages/overview/define-your-api/ferndef/api-yml.mdx index 6ae75b08e8..f41a0e636c 100644 --- a/fern/pages/overview/define-your-api/ferndef/api-yml.mdx +++ b/fern/pages/overview/define-your-api/ferndef/api-yml.mdx @@ -72,6 +72,41 @@ auth-schemes: ``` +Custom authentication schemes include custom `OAuth` integrations, too. Simply hook up +your OAuth token endpoint and (optionally) configure token refresh like so: + + +```yaml +name: api +imports: + auth: auth.yml +auth: OAuthScheme +auth-schemes: + OAuthScheme: + scheme: oauth + type: client-credentials + get-token: + endpoint: auth.getToken # Assumes the auth.yml file defines this endpoint. + response-properties: + access-token: $response.access_token + expires-in: $response.expires_in + refresh-token: + endpoint: auth.refreshToken # Assumes the auth.yml file defines this endpoint. + request-properties: + refresh-token: $request.refresh_token + response-properties: + access-token: $response.access_token + expires-in: $response.expires_in + refresh-token: $response.refresh_token +``` + + +The `request-properties` are the properties sent to the endpoint, whereas the `response-properties` are +used to identify and extract the OAuth access token details from the endpoint's response. + +With this, all of the OAuth logic happens automatically in the generated SDKs. As long as you configure your +`client-id` and `client-secret`, your client will automatcally retrieve an access token and refresh it as needed. + ## Global headers You can specify headers that are meant to be included on every request: @@ -98,7 +133,8 @@ path-parameters: ## Global query parameters -You cannot yet specify query parameters that are meant to be included on every request. If you'd like to see this feature, please upvote [this issue](https://github.com/fern-api/fern/issues/2930). +You cannot yet specify query parameters that are meant to be included on every request. +If you'd like to see this feature, please upvote [this issue](https://github.com/fern-api/fern/issues/2930). ## Environments