Add Client Api generation (#17961)
## Add API client generation to SDK dev mode and refactor orchestrator into step-based pipeline ### Why The SDK dev mode lacked typed API client generation, forcing developers to work without auto-generated GraphQL types when building applications. Additionally, the orchestrator was a monolithic class that mixed watcher management, token handling, and sync logic — making it difficult to extend with new steps like client generation. ### How - **Refactored the orchestrator** into a step-based pipeline with dedicated classes: `CheckServer`, `EnsureValidTokens`, `ResolveApplication`, `BuildManifest`, `UploadFiles`, `GenerateApiClient`, `SyncApplication`, and `StartWatchers`. Each step has typed input/output/status, managed by a new `OrchestratorState` class. - **Added `GenerateApiClientOrchestratorStep`** that detects object/field schema changes and regenerates a typed GraphQL client (via `@genql/cli`) into `node_modules/twenty-sdk/generated` for seamless imports. - **Replaced `checkApplicationExist`** with `findOneApplication` on both server resolver and SDK API service, returning the entity data instead of a boolean. - **Added application token pair mutations** (`generateApplicationToken`, `renewApplicationToken`) to the API service, with the server now returning `ApplicationTokenPairDTO` containing both access and refresh tokens. - **Restructured the dev UI** into `dev/ui/components/` with dedicated panel, section, and event log components. - **Simplified `AppDevCommand`** from ~180 lines of watcher management down to ~40 lines that delegate entirely to the orchestrator.
This commit is contained in:
@@ -3,16 +3,9 @@ import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs';
|
||||
import { createClient } from 'graphql-sse';
|
||||
import {
|
||||
buildClientSchema,
|
||||
getIntrospectionQuery,
|
||||
printSchema,
|
||||
} from 'graphql/index';
|
||||
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
type ApplicationManifest,
|
||||
type Manifest,
|
||||
} from 'twenty-shared/application';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type FileFolder } from 'twenty-shared/types';
|
||||
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
|
||||
import { pascalCase } from 'twenty-shared/utils';
|
||||
@@ -31,7 +24,7 @@ export class ApiService {
|
||||
|
||||
config.baseURL = twentyConfig.apiUrl;
|
||||
|
||||
if (twentyConfig.apiKey) {
|
||||
if (!config.headers.Authorization && twentyConfig.apiKey) {
|
||||
config.headers.Authorization = `Bearer ${twentyConfig.apiKey}`;
|
||||
}
|
||||
|
||||
@@ -102,15 +95,19 @@ export class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async checkApplicationExist(
|
||||
async findOneApplication(
|
||||
universalIdentifier: string,
|
||||
): Promise<ApiResponse<boolean>> {
|
||||
): Promise<ApiResponse<{ id: string; universalIdentifier: string } | null>> {
|
||||
try {
|
||||
const query = `
|
||||
query CheckApplicationExist($universalIdentifier: UUID!) {
|
||||
checkApplicationExist(universalIdentifier: $universalIdentifier)
|
||||
query FindOneApplication($universalIdentifier: UUID!) {
|
||||
findOneApplication(universalIdentifier: $universalIdentifier) {
|
||||
id
|
||||
universalIdentifier
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
@@ -125,6 +122,70 @@ export class ApiService {
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
const isNotFound = response.data.errors.some(
|
||||
(error: { extensions?: { code?: string } }) =>
|
||||
error.extensions?.code === 'NOT_FOUND',
|
||||
);
|
||||
|
||||
if (isNotFound) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: response.data.errors[0],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.findOneApplication,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async generateApplicationToken(applicationId: string): Promise<
|
||||
ApiResponse<{
|
||||
applicationAccessToken: { token: string; expiresAt: string };
|
||||
applicationRefreshToken: { token: string; expiresAt: string };
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation GenerateApplicationToken($applicationId: UUID!) {
|
||||
generateApplicationToken(applicationId: $applicationId) {
|
||||
applicationAccessToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
applicationRefreshToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query: mutation,
|
||||
variables: { applicationId },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -134,8 +195,62 @@ export class ApiService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.checkApplicationExist,
|
||||
message: `Successfully find application`,
|
||||
data: response.data.data.generateApplicationToken,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async renewApplicationToken(applicationRefreshToken: string): Promise<
|
||||
ApiResponse<{
|
||||
applicationAccessToken: { token: string; expiresAt: string };
|
||||
applicationRefreshToken: { token: string; expiresAt: string };
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation RenewApplicationToken($applicationRefreshToken: String!) {
|
||||
renewApplicationToken(applicationRefreshToken: $applicationRefreshToken) {
|
||||
applicationAccessToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
applicationRefreshToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query: mutation,
|
||||
variables: { applicationRefreshToken },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data.errors[0],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.renewApplicationToken,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -147,7 +262,7 @@ export class ApiService {
|
||||
|
||||
async createApplication(
|
||||
manifest: Manifest,
|
||||
): Promise<ApiResponse<ApplicationManifest>> {
|
||||
): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation CreateOneApplication($input: CreateApplicationInput!) {
|
||||
@@ -298,21 +413,27 @@ export class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async getSchema(): Promise<ApiResponse<string>> {
|
||||
async getSchema(options?: {
|
||||
authToken?: string;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
try {
|
||||
const introspectionQuery = getIntrospectionQuery();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
};
|
||||
|
||||
if (options?.authToken) {
|
||||
headers.Authorization = `Bearer ${options.authToken}`;
|
||||
}
|
||||
|
||||
const response = await this.client.post(
|
||||
'/graphql',
|
||||
{
|
||||
query: introspectionQuery,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
|
||||
Reference in New Issue
Block a user