Bỏ qua để đến nội dung

tRPC

tRPC là một framework để xây dựng API trong TypeScript với tính an toàn kiểu từ đầu đến cuối. Sử dụng tRPC, các cập nhật đối với đầu vào và đầu ra của các thao tác API sẽ được phản ánh ngay lập tức trong mã client và hiển thị trong IDE của bạn mà không cần rebuild dự án.

Trình tạo API tRPC tạo ra một API tRPC mới với cấu hình cơ sở hạ tầng AWS CDK hoặc Terraform. Backend được tạo ra sử dụng AWS Lambda cho triển khai serverless, được expose thông qua AWS API Gateway API, và bao gồm xác thực schema sử dụng Zod. Nó thiết lập AWS Lambda Powertools cho khả năng quan sát, bao gồm logging, AWS X-Ray tracing và Cloudwatch Metrics.

Bạn có thể tạo một API tRPC mới theo hai cách:

  1. Install the Nx Console VSCode Plugin if you haven't already
  2. Open the Nx Console in VSCode
  3. Click Generate (UI) in the "Common Nx Commands" section
  4. Search for @aws/nx-plugin - ts#trpc-api
  5. Fill in the required parameters
    • Click Generate
    Parameter Type Default Description
    name Required string - The name of the API (required). Used to generate class names and file paths.
    computeType string ServerlessApiGatewayRestApi The type of compute to use to deploy this API. Choose between ServerlessApiGatewayRestApi (default) or ServerlessApiGatewayHttpApi.
    auth string IAM The method used to authenticate with your API. Choose between IAM (default), Cognito or None.
    directory string packages The directory to store the application in.
    iacProvider string Inherit The preferred IaC provider. By default this is inherited from your initial selection.

    Trình tạo sẽ tạo cấu trúc dự án sau trong thư mục <directory>/<api-name>:

    • Thư mụcsrc
      • init.ts Khởi tạo tRPC Backend
      • router.ts Định nghĩa tRPC router (điểm vào API của Lambda handler)
      • Thư mụcschema Định nghĩa schema sử dụng Zod
        • echo.ts Định nghĩa ví dụ cho đầu vào và đầu ra của procedure “echo”
      • Thư mụcprocedures Các procedure (hoặc operation) được expose bởi API của bạn
        • echo.ts Procedure ví dụ
      • Thư mụcmiddleware
        • error.ts Middleware để xử lý lỗi
        • logger.ts middleware để cấu hình AWS Powertools for Lambda logging
        • tracer.ts middleware để cấu hình AWS Powertools for Lambda tracing
        • metrics.ts middleware để cấu hình AWS Powertools for Lambda metrics
      • local-server.ts Điểm vào tRPC standalone adapter cho máy chủ phát triển cục bộ
      • Thư mụcclient
        • index.ts Client an toàn kiểu cho các lời gọi API máy-đến-máy
    • tsconfig.json Cấu hình TypeScript
    • project.json Cấu hình dự án và các build target

    Vì generator này cung cấp infrastructure as code dựa trên iacProvider bạn đã chọn, nó sẽ tạo một dự án trong packages/common bao gồm các CDK constructs hoặc Terraform modules liên quan.

    Dự án infrastructure as code chung được cấu trúc như sau:

    • Thư mụcpackages/common/constructs
      • Thư mụcsrc
        • Thư mụcapp/ Constructs cho infrastructure cụ thể của một dự án/generator
        • Thư mụccore/ Constructs chung được tái sử dụng bởi các constructs trong app
        • index.ts Entry point xuất các constructs từ app
      • project.json Các build targets và cấu hình của dự án

    Để triển khai API của bạn, các tệp sau được tạo ra:

    • Thư mụcpackages/common/constructs/src
      • Thư mụcapp
        • Thư mụcapis
          • <project-name>.ts CDK construct để triển khai API của bạn
      • Thư mụccore
        • Thư mụcapi
          • http-api.ts CDK construct để triển khai HTTP API (nếu bạn chọn triển khai HTTP API)
          • rest-api.ts CDK construct để triển khai REST API (nếu bạn chọn triển khai REST API)
          • utils.ts Các tiện ích cho API constructs

    Ở mức độ cao, các API tRPC bao gồm một router ủy quyền các yêu cầu đến các procedure cụ thể. Mỗi procedure có một đầu vào và đầu ra, được định nghĩa dưới dạng Zod schema.

    Thư mục src/schema chứa các kiểu được chia sẻ giữa mã client và server của bạn. Trong package này, các kiểu này được định nghĩa sử dụng Zod, một thư viện khai báo và xác thực schema ưu tiên TypeScript.

    Một schema ví dụ có thể trông như sau:

    import { z } from 'zod';
    // Định nghĩa schema
    export const UserSchema = z.object({
    name: z.string(),
    height: z.number(),
    dateOfBirth: z.string().datetime(),
    });
    // Kiểu TypeScript tương ứng
    export type User = z.TypeOf<typeof UserSchema>;

    Với schema trên, kiểu User tương đương với TypeScript sau:

    interface User {
    name: string;
    height: number;
    dateOfBirth: string;
    }

    Các schema được chia sẻ bởi cả mã server và client, cung cấp một nơi duy nhất để cập nhật khi thực hiện thay đổi đối với các cấu trúc được sử dụng trong API của bạn.

    Các schema được tự động xác thực bởi API tRPC của bạn tại runtime, giúp tiết kiệm việc tạo logic xác thực tùy chỉnh thủ công trong backend của bạn.

    Zod cung cấp các tiện ích mạnh mẽ để kết hợp hoặc tạo schema như .merge, .pick, .omit và nhiều hơn nữa. Bạn có thể tìm thêm thông tin trên trang tài liệu Zod.

    Bạn có thể tìm thấy điểm vào cho api của bạn trong src/router.ts. File này chứa lambda handler định tuyến các yêu cầu đến “procedure” dựa trên operation đang được gọi. Mỗi procedure định nghĩa đầu vào, đầu ra và triển khai mong đợi.

    Router mẫu được tạo cho bạn có một operation duy nhất, được gọi là echo:

    import { echo } from './procedures/echo.js';
    export const appRouter = router({
    echo,
    });

    Procedure echo ví dụ được tạo cho bạn trong src/procedures/echo.ts:

    export const echo = publicProcedure
    .input(EchoInputSchema)
    .output(EchoOutputSchema)
    .query((opts) => ({ result: opts.input.message }));

    Để phân tích đoạn mã trên:

    • publicProcedure định nghĩa một phương thức công khai trên API, bao gồm middleware được thiết lập trong src/middleware. Middleware này bao gồm tích hợp AWS Lambda Powertools cho logging, tracing và metrics.
    • input chấp nhận một Zod schema định nghĩa đầu vào mong đợi cho operation. Các yêu cầu được gửi cho operation này được tự động xác thực với schema này.
    • output chấp nhận một Zod schema định nghĩa đầu ra mong đợi cho operation. Bạn sẽ thấy lỗi kiểu trong triển khai của bạn nếu bạn không trả về đầu ra phù hợp với schema.
    • query chấp nhận một hàm định nghĩa triển khai cho API của bạn. Triển khai này nhận opts, chứa input được truyền đến operation của bạn, cũng như context khác được thiết lập bởi middleware, có sẵn trong opts.ctx. Hàm được truyền cho query phải trả về một đầu ra phù hợp với schema output.

    Việc sử dụng query để định nghĩa triển khai cho biết rằng operation không có tính thay đổi (mutative). Sử dụng điều này để định nghĩa các phương thức để truy xuất dữ liệu. Để triển khai một operation có tính thay đổi, hãy sử dụng phương thức mutation thay thế.

    Nếu bạn thêm một procedure mới, hãy đảm bảo bạn đăng ký nó bằng cách thêm nó vào router trong src/router.ts.

    Trong triển khai của bạn, bạn có thể trả về các phản hồi lỗi cho client bằng cách throw một TRPCError. Chúng chấp nhận một code cho biết loại lỗi, ví dụ:

    throw new TRPCError({
    code: 'NOT_FOUND',
    message: 'The requested resource could not be found',
    });

    Khi API của bạn phát triển, bạn có thể muốn nhóm các operation liên quan lại với nhau.

    Bạn có thể nhóm các operation lại với nhau bằng cách sử dụng các router lồng nhau, ví dụ:

    import { getUser } from './procedures/users/get.js';
    import { listUsers } from './procedures/users/list.js';
    const appRouter = router({
    users: router({
    get: getUser,
    list: listUsers,
    }),
    ...
    })

    Client sau đó nhận được nhóm operation này, ví dụ gọi operation listUsers trong trường hợp này có thể trông như sau:

    client.users.list.query();

    AWS Lambda Powertools logger được cấu hình trong src/middleware/logger.ts, và có thể được truy cập trong triển khai API thông qua opts.ctx.logger. Bạn có thể sử dụng điều này để log vào CloudWatch Logs, và/hoặc kiểm soát các giá trị bổ sung để bao gồm trong mọi thông báo log có cấu trúc. Ví dụ:

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    opts.ctx.logger.info('Operation called with input', opts.input);
    return ...;
    });

    Để biết thêm thông tin về logger, vui lòng tham khảo tài liệu AWS Lambda Powertools Logger.

    AWS Lambda Powertools metrics được cấu hình trong src/middleware/metrics.ts, và có thể được truy cập trong triển khai API thông qua opts.ctx.metrics. Bạn có thể sử dụng điều này để ghi lại metrics trong CloudWatch mà không cần import và sử dụng AWS SDK, ví dụ:

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    opts.ctx.metrics.addMetric('Invocations', 'Count', 1);
    return ...;
    });

    Để biết thêm thông tin, vui lòng tham khảo tài liệu AWS Lambda Powertools Metrics.

    AWS Lambda Powertools tracer được cấu hình trong src/middleware/tracer.ts, và có thể được truy cập trong triển khai API thông qua opts.ctx.tracer. Bạn có thể sử dụng điều này để thêm trace với AWS X-Ray để cung cấp thông tin chi tiết về hiệu suất và luồng của các yêu cầu API. Ví dụ:

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    const subSegment = opts.ctx.tracer.getSegment()!.addNewSubsegment('MyAlgorithm');
    // ... logic thuật toán của tôi để capture
    subSegment.close();
    return ...;
    });

    Để biết thêm thông tin, vui lòng tham khảo tài liệu AWS Lambda Powertools Tracer.

    Bạn có thể thêm các giá trị bổ sung vào context được cung cấp cho các procedure bằng cách triển khai middleware.

    Ví dụ, hãy triển khai một số middleware để trích xuất một số chi tiết về người dùng đang gọi từ API của chúng ta trong src/middleware/identity.ts.

    Ví dụ này giả định auth được đặt thành IAM. Đối với xác thực Cognito, middleware identity đơn giản hơn, trích xuất các claim liên quan từ event.

    Đầu tiên, chúng ta định nghĩa những gì chúng ta sẽ thêm vào context:

    export interface IIdentityContext {
    identity?: {
    sub: string;
    username: string;
    };
    }

    Lưu ý rằng chúng ta định nghĩa một thuộc tính tùy chọn bổ sung cho context. tRPC quản lý đảm bảo rằng điều này được định nghĩa trong các procedure đã cấu hình middleware này một cách chính xác.

    Tiếp theo, chúng ta sẽ triển khai middleware đó. Điều này có cấu trúc sau:

    export const createIdentityPlugin = () => {
    const t = initTRPC.context<...>().create();
    return t.procedure.use(async (opts) => {
    // Thêm logic ở đây để chạy trước procedure
    const response = await opts.next(...);
    // Thêm logic ở đây để chạy sau procedure
    return response;
    });
    };

    Trong trường hợp của chúng ta, chúng ta muốn trích xuất chi tiết về người dùng Cognito đang gọi. Chúng ta sẽ thực hiện điều đó bằng cách trích xuất ID subject của người dùng (hoặc “sub”) từ API Gateway event, và truy xuất chi tiết người dùng từ Cognito. Triển khai thay đổi một chút tùy thuộc vào việc event được cung cấp cho function của chúng ta bởi REST API hay HTTP API:

    import { CognitoIdentityProvider } from '@aws-sdk/client-cognito-identity-provider';
    import { initTRPC, TRPCError } from '@trpc/server';
    import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';
    import { APIGatewayProxyEvent } from 'aws-lambda';
    export interface IIdentityContext {
    identity?: {
    sub: string;
    username: string;
    };
    }
    export const createIdentityPlugin = () => {
    const t = initTRPC.context<IIdentityContext & CreateAWSLambdaContextOptions<APIGatewayProxyEvent>>().create();
    const cognito = new CognitoIdentityProvider();
    return t.procedure.use(async (opts) => {
    const cognitoAuthenticationProvider = opts.ctx.event.requestContext?.identity?.cognitoAuthenticationProvider;
    let sub: string | undefined = undefined;
    if (cognitoAuthenticationProvider) {
    const providerParts = cognitoAuthenticationProvider.split(':');
    sub = providerParts[providerParts.length - 1];
    }
    if (!sub) {
    throw new TRPCError({
    code: 'FORBIDDEN',
    message: `Unable to determine calling user`,
    });
    }
    const { Users } = await cognito.listUsers({
    // Giả định user pool id được cấu hình trong môi trường lambda
    UserPoolId: process.env.USER_POOL_ID!,
    Limit: 1,
    Filter: `sub="${sub}"`,
    });
    if (!Users || Users.length !== 1) {
    throw new TRPCError({
    code: 'FORBIDDEN',
    message: `No user found with subjectId ${sub}`,
    });
    }
    // Cung cấp identity cho các procedure khác trong context
    return await opts.next({
    ctx: {
    ...opts.ctx,
    identity: {
    sub,
    username: Users[0].Username!,
    },
    },
    });
    });
    };

    Trình tạo API tRPC tạo mã cơ sở hạ tầng CDK hoặc Terraform dựa trên iacProvider bạn đã chọn. Bạn có thể sử dụng điều này để triển khai API tRPC của bạn.

    CDK construct để triển khai API của bạn trong thư mục common/constructs. Bạn có thể sử dụng nó trong ứng dụng CDK, ví dụ:

    import { MyApi } from ':my-scope/common-constructs`;
    export class ExampleStack extends Stack {
    constructor(scope: Construct, id: string) {
    // Thêm api vào stack của bạn
    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    }
    }

    Điều này thiết lập cơ sở hạ tầng API của bạn, bao gồm AWS API Gateway REST hoặc HTTP API, các hàm AWS Lambda cho logic nghiệp vụ, và xác thực dựa trên phương thức auth bạn đã chọn.

    Các construct CDK của REST/HTTP API được cấu hình để cung cấp giao diện type-safe cho việc định nghĩa các tích hợp cho từng operation của bạn.

    Các construct CDK cung cấp hỗ trợ tích hợp type-safe đầy đủ như mô tả bên dưới.

    Bạn có thể sử dụng defaultIntegrations tĩnh để sử dụng pattern mặc định, định nghĩa một AWS Lambda function riêng biệt cho mỗi operation:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });

    Bạn có thể truy cập các AWS Lambda function bên dưới thông qua thuộc tính integrations của construct API, theo cách type-safe. Ví dụ, nếu API của bạn định nghĩa một operation tên là sayHello và bạn cần thêm một số quyền cho function này, bạn có thể làm như sau:

    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    // sayHello được type theo các operation được định nghĩa trong API của bạn
    api.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({
    effect: Effect.ALLOW,
    actions: [...],
    resources: [...],
    }));

    Nếu bạn muốn tùy chỉnh các tùy chọn được sử dụng khi tạo Lambda function cho mỗi tích hợp mặc định, bạn có thể sử dụng phương thức withDefaultOptions. Ví dụ, nếu bạn muốn tất cả các Lambda function của mình nằm trong một Vpc:

    const vpc = new Vpc(this, 'Vpc', ...);
    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withDefaultOptions({
    vpc,
    })
    .build(),
    });

    Bạn cũng có thể ghi đè các tích hợp cho các operation cụ thể bằng phương thức withOverrides. Mỗi ghi đè phải chỉ định một thuộc tính integration được type theo construct tích hợp CDK phù hợp cho HTTP hoặc REST API. Phương thức withOverrides cũng là type-safe. Ví dụ, nếu bạn muốn ghi đè một API getDocumentation để trỏ đến tài liệu được lưu trữ bởi một trang web bên ngoài, bạn có thể thực hiện như sau:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withOverrides({
    getDocumentation: {
    integration: new HttpIntegration('https://example.com/documentation'),
    },
    })
    .build(),
    });

    Bạn cũng sẽ nhận thấy rằng tích hợp được ghi đè không còn có thuộc tính handler khi truy cập nó thông qua api.integrations.getDocumentation.

    Bạn có thể thêm các thuộc tính bổ sung vào một tích hợp cũng sẽ được type tương ứng, cho phép các loại tích hợp khác được trừu tượng hóa nhưng vẫn giữ type-safe, ví dụ nếu bạn đã tạo một tích hợp S3 cho REST API và sau này muốn tham chiếu bucket cho một operation cụ thể, bạn có thể làm như sau:

    const storageBucket = new Bucket(this, 'Bucket', { ... });
    const apiGatewayRole = new Role(this, 'ApiGatewayS3Role', {
    assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
    });
    storageBucket.grantRead(apiGatewayRole);
    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withOverrides({
    getFile: {
    bucket: storageBucket,
    integration: new AwsIntegration({
    service: 's3',
    integrationHttpMethod: 'GET',
    path: `${storageBucket.bucketName}/{fileName}`,
    options: {
    credentialsRole: apiGatewayRole,
    requestParameters: {
    'integration.request.path.fileName': 'method.request.querystring.fileName',
    },
    integrationResponses: [{ statusCode: '200' }],
    },
    }),
    options: {
    requestParameters: {
    'method.request.querystring.fileName': true,
    },
    methodResponses: [{
    statusCode: '200',
    }],
    }
    },
    })
    .build(),
    });
    // Sau này, có thể trong một file khác, bạn có thể truy cập thuộc tính bucket mà chúng ta đã định nghĩa
    // theo cách type-safe
    api.integrations.getFile.bucket.grantRead(...);

    Bạn cũng có thể cung cấp options trong tích hợp của mình để ghi đè các tùy chọn method cụ thể như authorizer, ví dụ nếu bạn muốn sử dụng xác thực Cognito cho operation getDocumentation của mình:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withOverrides({
    getDocumentation: {
    integration: new HttpIntegration('https://example.com/documentation'),
    options: {
    authorizer: new CognitoUserPoolsAuthorizer(...) // cho REST, hoặc HttpUserPoolAuthorizer cho HTTP API
    }
    },
    })
    .build(),
    });

    Nếu bạn muốn, bạn có thể chọn không sử dụng các tích hợp mặc định và thay vào đó cung cấp trực tiếp một tích hợp cho mỗi operation. Điều này hữu ích nếu, ví dụ, mỗi operation cần sử dụng một loại tích hợp khác nhau hoặc bạn muốn nhận lỗi type khi thêm các operation mới:

    new MyApi(this, 'MyApi', {
    integrations: {
    sayHello: {
    integration: new LambdaIntegration(...),
    },
    getDocumentation: {
    integration: new HttpIntegration(...),
    },
    },
    });

    Nếu bạn muốn triển khai một Lambda function duy nhất để phục vụ tất cả các request API, bạn có thể tự do chỉnh sửa phương thức defaultIntegrations cho API của mình để tạo một function duy nhất thay vì một function cho mỗi tích hợp:

    packages/common/constructs/src/app/apis/my-api.ts
    export class MyApi<...> extends ... {
    public static defaultIntegrations = (scope: Construct) => {
    const router = new Function(scope, 'RouterHandler', { ... });
    return IntegrationBuilder.rest({
    ...
    defaultIntegrationOptions: {},
    buildDefaultIntegration: (op) => {
    return {
    // Tham chiếu cùng một router lambda handler trong mọi tích hợp
    integration: new LambdaIntegration(router),
    };
    },
    });
    };
    }

    Bạn có thể sửa đổi code theo các cách khác nếu muốn, ví dụ bạn có thể muốn định nghĩa function router như một tham số cho defaultIntegrations thay vì xây dựng nó trong phương thức.

    Nếu bạn đã chọn sử dụng xác thực IAM, bạn có thể cấp quyền truy cập vào API của bạn:

    api.grantInvokeAccess(myIdentityPool.authenticatedRole);

    Generator tự động cấu hình một target bundle sử dụng Rolldown để tạo gói triển khai:

    Terminal window
    pnpm nx run <project-name>:bundle

    Cấu hình Rolldown có thể được tìm thấy trong rolldown.config.ts, với một entry cho mỗi bundle cần tạo. Rolldown quản lý việc tạo nhiều bundle song song nếu được định nghĩa.

    Bạn có thể sử dụng target serve để chạy máy chủ cục bộ cho API của bạn, ví dụ:

    Terminal window
    pnpm nx run @my-scope/my-api:serve

    Điểm vào cho máy chủ cục bộ là src/local-server.ts.

    Điều này sẽ tự động reload khi bạn thực hiện thay đổi đối với API của bạn.

    Bạn có thể tạo một tRPC client để gọi API của bạn theo cách an toàn kiểu. Nếu bạn đang gọi API tRPC của bạn từ backend khác, bạn có thể sử dụng client trong src/client/index.ts, ví dụ:

    import { createMyApiClient } from ':my-scope/my-api';
    const client = createMyApiClient({ url: 'https://my-api-url.example.com/' });
    await client.echo.query({ message: 'Hello world!' });

    Nếu bạn đang gọi API của bạn từ một website React, hãy xem xét sử dụng trình tạo API Connection để cấu hình client.

    Để biết thêm thông tin về tRPC, vui lòng tham khảo tài liệu tRPC.