Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feature): Add OAuth YAML and validator #3403

Merged
merged 3 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion fern/pages/overview/define-your-api/ferndef/api-yml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,41 @@ auth-schemes:
```
</CodeBlock>

Custom authentication schemes include custom `OAuth` integrations, too. Simply hook up
your OAuth token endpoint and (optionally) configure token refresh like so:

<CodeBlock title="api.yml">
```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
```
</CodeBlock>

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:
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ function convertSchemeReference({
file,
docs,
rawScheme
}),
oauth: (rawScheme) =>
generateOAuth({
file,
docs,
rawScheme
})
});
};
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/generation/ir-generator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Original file line number Diff line number Diff line change
@@ -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
})
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { RawSchemas } from "@fern-api/yaml-schema";

export interface ResolvedEndpoint {
endpointId: string;
endpoint: RawSchemas.HttpEndpointSchema;
}
Original file line number Diff line number Diff line change
@@ -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<string, RelativeFilePath>;
}): 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 })
};
}
2 changes: 2 additions & 0 deletions packages/cli/yaml/validator/src/getAllRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -73,6 +74,7 @@ export function getAllRules(): Rule[] {
OnlyObjectExtensionsRule,
NoMaybeStreamingRule,
NoResponsePropertyRule,
ValidOauthRule,
ValidPaginationRule
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
types:
TokenResponse:
docs: |
An OAuth token response.
properties:
access_token: string
expires_in: integer
refresh_token: optional<string>

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<string>
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<string>
response: TokenResponse
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
types:
TokenResponse:
docs: |
An OAuth token response.
properties:
access_token: string
expires_in: integer
refresh_token: optional<string>

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<string>
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<string>
response: TokenResponse
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Loading
Loading