빠른 시작: TypeSpec 및 TypeScript를 사용하여 새 API 프로젝트 만들기

이 빠른 시작에서는 TypeSpec을 사용하여 RESTful TypeScript API 애플리케이션을 디자인, 생성 및 구현하는 방법을 알아봅니다. TypeSpec은 클라우드 서비스 API를 설명하기 위한 오픈 소스 언어이며 여러 플랫폼에 대한 클라이언트 및 서버 코드를 생성합니다. 이 빠른 시작에 따라 API 계약을 한 번 정의하고 일관된 구현을 생성하여 유지 관리가 가능하고 잘 문서화된 API 서비스를 빌드하는 방법을 알아봅니다.

TypeSpec의 코드 생성기를 사용하여 라우팅 및 Swagger UI를 사용하여 Express.js 서버를 스캐폴드하고, 지속성을 위해 Azure Cosmos DB에 연결하고, Azure Container Apps에 배포합니다.

API 개발에서 TypeSpec의 역할에 대한 컨텍스트는 TypeSpec 개요를 참조하세요.

완료 시간: 15~20분

이 빠른 시작에서 당신은 다음을 수행합니다.

  • TypeSpec을 사용하여 API 정의
  • API 서버 애플리케이션 만들기
  • 영구 스토리지에 Azure Cosmos DB 통합
  • Azure에 배포
  • API 실행 및 테스트

Important

@typespec/http-server-js 이미터는 현재 미리 보기로 제공됩니다. 이 정보는 릴리스되기 전에 상당히 수정될 수 있는 시험판 제품과 관련이 있습니다. Microsoft는 여기에 제공된 정보와 관련하여 명시적이거나 묵시적인 보증을 하지 않습니다.

Prerequisites

TypeSpec을 사용하여 개발

TypeSpec은 언어에 구애받지 않은 방식으로 API를 정의하고 여러 플랫폼에 대한 API 서버 및 클라이언트 라이브러리를 생성합니다. 이 기능을 통해 다음을 수행할 수 있습니다.

  • API 계약을 한 번 정의
  • 일관된 서버 및 클라이언트 코드 생성
  • API 인프라가 아닌 비즈니스 논리 구현에 집중

TypeSpec은 API 서비스 관리를 제공합니다.

  • API 정의 언어
  • API용 서버 쪽 라우팅 미들웨어
  • API를 사용하는 클라이언트 라이브러리

클라이언트 요청 및 서버 통합을 제공합니다.

  • 데이터베이스, 스토리지 및 메시징에 대한 Azure 서비스와 같은 미들웨어에서 비즈니스 논리 구현
  • API용 호스팅 서버(로컬 또는 Azure)
  • 반복 가능한 프로비전 및 배포를 위한 배포 스크립트

새 TypeSpec 애플리케이션 만들기

  1. API 서버 및 TypeSpec 파일을 저장할 새 폴더를 만듭니다.

    mkdir my_typespec_quickstart
    cd my_typespec_quickstart
    
  2. TypeSpec 컴파일러를 전역적으로 설치합니다.

    npm install -g @typespec/compiler
    
  3. TypeSpec이 올바르게 설치되었는지 확인합니다.

    tsp --version
    
  4. TypeSpec 프로젝트를 초기화합니다.

    tsp init
    
  5. 제공된 답변으로 다음 프롬프트에 응답합니다.

    • 여기에서 새 프로젝트를 초기화하시겠습니까? Y
    • 프로젝트 템플릿을 선택하시겠습니까? 일반 REST API
    • 프로젝트 이름 입력: 위젯
    • 어떤 내보내기를 사용하시겠습니까?
      • OpenAPI 3.1 문서
      • JavaScript 서버 스텁

    TypeSpec 발생기는 다양한 TypeSpec 컴파일러 API를 활용하여 TypeSpec 컴파일 프로세스를 고찰하고 아티팩트를 생성하는 라이브러리입니다.

  6. 계속하기 전에 초기화가 완료되기를 기다립니다.

  7. 프로젝트를 컴파일합니다.

    tsp compile .
    

    팁 (조언)

    반복 개발의 경우 변경 감시 모드를 사용하여 파일 변경 시 tsp compile . --watch 자동으로 다시 컴파일합니다. 이렇게 하면 수정 main.tsp할 때 생성된 서버와 스키마가 최신 상태로 유지됩니다.

  8. TypeSpec은 두 개의 개별 폴더를 만드는 기본 ./tsp-output프로젝트를 생성합니다.

    • 스키마 는 OpenApi 3 사양입니다. ./main.tsp의 몇 줄이 여러분을 위해 200줄이 넘는 OpenApi 사양을 생성한 것을 주목하세요.
    • 서버 는 생성된 미들웨어입니다. 이 미들웨어는 Node.js 서버 프로젝트에 통합할 수 있습니다.
      • ./tsp-output/server/js/src/generated/models/all/demo-service.ts 는 위젯 API에 대한 인터페이스를 정의합니다.
      • ./tsp-output/server/js/src/generated/http/openapi3.ts 는 Open API 사양을 TypeScript 파일로 정의하고 TypeSpec 프로젝트를 컴파일할 때마다 다시 생성됩니다.

TypeSpec 방출기 구성

TypeSpec 파일을 사용하여 전체 Express.js 서버를 스캐폴드하도록 API 서버 생성을 구성합니다.

  1. 기존 구성을 ./tspconfig.yaml 열고 다음 YAML로 바꿉다.

    emit:
      - "@typespec/openapi3"
      - "@typespec/http-server-js"
    options:
      "@typespec/openapi3":
        emitter-output-dir: "{output-dir}/server/schema"
        openapi-versions:
          - 3.1.0
      "@typespec/http-server-js":
        emitter-output-dir: "{output-dir}/server"
        express: true
    

    이 구성은 전체 Express.js API 서버를 만듭니다.

    • express: Swagger UI를 포함하여 Express.js API 서버를 생성합니다.
    • emitter-output-dir: 모든 항목을 ./server 디렉터리에 생성합니다.
  2. 기존 ./tsp-output를 삭제하십시오. 걱정하지 마세요. 다음 단계에서 서버를 생성합니다.

  3. TypeSpec JavaScript 방출기를 사용하여 Express.js 서버를 만듭니다.

    npx hsjs-scaffold
    
  4. 새 ./tsp-output/server 디렉터리로 변경합니다.

    cd ./tsp-output/server
    
  5. TypeScript를 JavaScript로 컴파일합니다.

    tsc
    
  6. 프로젝트를 실행합니다.

    npm start
    

    브라우저에서 알림이 열릴 때까지 기다립니다.

  7. 브라우저를 열고 http://localhost:3000/.api-docs 웹 사이트로 이동합니다.

    위젯 API용 Swagger UI를 표시하는 브라우저의 스크린샷

  8. 기본 TypeSpec API와 서버가 모두 작동합니다. 이 API 서버를 완성하려면 비즈니스 논리를 추가하여 위젯 API ./tsp-output/server/src/controllers/widgets.ts를 지원해야 합니다. UI는 하드 코딩된 가짜 데이터를 반환하는 API에 연결됩니다.

애플리케이션 파일 구조 이해

Express.js 프로젝트 구조 tsp-output/server/ 에는 생성된 서버, package.json및 Azure 통합을 위한 미들웨어가 포함됩니다.

server
├── package.json
├── package-lock.json
├── src
│   ├── controllers
│   │   └── widgets.ts
│   ├── generated
│   │   ├── helpers
│   │   │   ├── datetime.ts
│   │   │   ├── header.ts
│   │   │   ├── http.ts
│   │   │   ├── multipart.ts
│   │   │   ├── router.ts
│   │   │   └── temporal
│   │   │       ├── native.ts
│   │   │       └── polyfill.ts
│   │   ├── http
│   │   │   ├── openapi3.ts
│   │   │   ├── operations
│   │   │   │   └── server-raw.ts
│   │   │   └── router.ts
│   │   └── models
│   │       └── all
│   │           ├── demo-service.ts
│   │           └── typespec.ts
│   ├── index.ts
│   └── swagger-ui.ts

부모 TypeSpec 프로젝트의 파일 구조에는 다음 Express.js 프로젝트가 포함됩니다.tsp-output

├── tsp-output
├── .gitignore
├── main.tsp
├── package-lock.json
├── package.json
├── tspconfig.yaml

Azure Cosmos DB no-sql로 지속성 변경

이제 기본 Express.js API 서버가 작동하므로 영구 데이터 저장소에 대한 Azure Cosmos DB 와 함께 작동하도록 Express.js 서버를 업데이트합니다. 여기에는 미들웨어에서 index.ts Cosmos DB 통합을 사용하도록 변경된 내용이 포함됩니다. 모든 변경 내용은 ./tsp-output/server/src/generated 디렉터리 외부에서 일어나야 합니다.

  1. ./tsp-output/server 디렉터리에서 프로젝트에 Azure Cosmos DB를 추가합니다.

    npm install @azure/cosmos
    
  2. Azure ID 라이브러리를 추가하여 Azure에 인증합니다.

    npm install @azure/identity
    
  3. Azure와 ./tsp-output/server/src/azure 관련된 소스 코드를 저장할 디렉터리를 만듭니다.

  4. cosmosClient.ts 해당 디렉터리에 파일을 만들어 Cosmos DB 클라이언트 개체를 만들고 다음 코드에 붙여넣습니다.

    import { CosmosClient, Database, Container } from "@azure/cosmos";
    import { DefaultAzureCredential } from "@azure/identity";
    
    /**
     * Interface for CosmosDB configuration settings
     */
    export interface CosmosConfig {
      endpoint: string;
      databaseId: string;
      containerId: string;
      partitionKey: string;
    } 
    
    /**
     * Singleton class for managing CosmosDB connections
     */
    export class CosmosClientManager {
      private static instance: CosmosClientManager;
      private client: CosmosClient | null = null;
      private config: CosmosConfig | null = null;
    
      private constructor() {}
    
      /**
       * Get the singleton instance of CosmosClientManager
       */
      public static getInstance(): CosmosClientManager {
        if (!CosmosClientManager.instance) {
          CosmosClientManager.instance = new CosmosClientManager();
        }
        return CosmosClientManager.instance;
      }
    
      /**
       * Initialize the CosmosDB client with configuration if not already initialized
       * @param config CosmosDB configuration
       */
      private ensureInitialized(config: CosmosConfig): void {
        if (!this.client || !this.config) {
          this.config = config;
          this.client = new CosmosClient({
            endpoint: config.endpoint,
            aadCredentials: new DefaultAzureCredential(),
          });
        }
      }
    
      /**
       * Get a database instance, creating it if it doesn't exist
       * @param config CosmosDB configuration
       * @returns Database instance
       */
      private async getDatabase(config: CosmosConfig): Promise<Database> {
        this.ensureInitialized(config);
        const { database } = await this.client!.databases.createIfNotExists({ id: config.databaseId });
        return database;
      }
    
      /**
       * Get a container instance, creating it if it doesn't exist
       * @param config CosmosDB configuration
       * @returns Container instance
       */
      public async getContainer(config: CosmosConfig): Promise<Container> {
        const database = await this.getDatabase(config);
        const { container } = await database.containers.createIfNotExists({
          id: config.containerId,
          partitionKey: { paths: [config.partitionKey] }
        });
        return container;
      }
    
      /**
       * Clean up resources and close connections
       */
      public dispose(): void {
        this.client = null;
        this.config = null;
      }
    }
    
    export const buildError = (error: any, message: string) => {
      const statusCode = error?.statusCode || 500;
      return {
        code: statusCode,
        message: `${message}: ${error?.message || 'Unknown error'}`
      };
    };
    

    파일은 엔드포인트, 데이터베이스 및 컨테이너를 사용합니다. Azure ID 자격 증명 DefaultAzureCredential을 사용하므로 연결 문자열이나 키가 필요하지 않습니다. 로컬 및 프로덕션 환경 모두에 대해 이 보안 인증 방법에 대해 자세히 알아봅니다.

  5. 새 위젯 컨트롤러 ./tsp-output/server/src/controllers/WidgetsCosmos.ts를 만들고 Azure Cosmos DB에 대한 다음 통합 코드를 붙여넣습니다.

    import { Widgets, Widget, WidgetList,   AnalyzeResult,Error } from "../generated/models/all/demo-service.js";
    import { WidgetMergePatchUpdate } from "../generated/models/all/typespec/http.js";
    import { CosmosClientManager, CosmosConfig, buildError } from "../azure/cosmosClient.js";
    import { HttpContext } from "../generated/helpers/router.js";
    import { Container } from "@azure/cosmos";
    
    export interface WidgetDocument extends Widget {
      _ts?: number;
      _etag?: string;
    }
    
    /**
     * Implementation of the Widgets API using Azure Cosmos DB for storage
     */
    export class WidgetsCosmosController implements Widgets<HttpContext>  {
      private readonly cosmosConfig: CosmosConfig;
      private readonly cosmosManager: CosmosClientManager;
      private container: Container | null = null;
    
      /**
       * Creates a new instance of WidgetsCosmosController
       * @param azureCosmosEndpoint Cosmos DB endpoint URL
       * @param databaseId The Cosmos DB database ID
       * @param containerId The Cosmos DB container ID
       * @param partitionKey The partition key path
       */
      constructor(azureCosmosEndpoint: string, databaseId: string, containerId: string, partitionKey: string) {
        if (!azureCosmosEndpoint) throw new Error("azureCosmosEndpoint is required");
        if (!databaseId) throw new Error("databaseId is required");
        if (!containerId) throw new Error("containerId is required");
        if (!partitionKey) throw new Error("partitionKey is required");
    
        this.cosmosConfig = {
          endpoint: azureCosmosEndpoint,
          databaseId: databaseId,
          containerId: containerId,
          partitionKey: partitionKey
        };
    
        this.cosmosManager = CosmosClientManager.getInstance();
      }
    
      /**
       * Get the container reference, with caching
       * @returns The Cosmos container instance
       */
      private async getContainer(): Promise<Container | null> {
        if (!this.container) {
          try {
            this.container = await this.cosmosManager.getContainer(this.cosmosConfig);
            return this.container;
          } catch (error: any) {
            console.error("Container initialization error:", error);
            throw buildError(error, `Failed to access container ${this.cosmosConfig.containerId}`);
          }
        }
        return this.container;
      }
    
      /**
       * Create a new widget
       * @param widget The widget to create
       * @returns The created widget with assigned ID
       */
      async create(ctx: HttpContext,
        body: Widget
      ): Promise<Widget | Error> {
    
        const id = body.id;
    
        try {
          const container = await this.getContainer();
    
          if(!container) {
            return buildError({statusCode:500}, "Container is not initialized");
          }
    
          if (!body.id) {
            return buildError({statusCode:400}, "Widget ID is required");
          }
    
          const response = await container.items.create<Widget>(body, { 
            disableAutomaticIdGeneration: true 
          });
    
          if (!response.resource) {
            return buildError({statusCode:500}, `Failed to create widget ${body.id}: No resource returned`);
          }
    
          return this.documentToWidget(response.resource);
        } catch (error: any) {
          if (error?.statusCode === 409) {
            return buildError({statusCode:409}, `Widget with id ${id} already exists`);
          }
          return buildError(error, `Failed to create widget ${id}`);
        }
      }
    
      /**
       * Delete a widget by ID
       * @param id The ID of the widget to delete
       */
      async delete(ctx: HttpContext, id: string): Promise<void | Error> {
        try {
          const container = await this.getContainer();
    
          if(!container) {
            return buildError({statusCode:500}, "Container is not initialized");
          }
    
          await container.item(id, id).delete();
        } catch (error: any) {
          if (error?.statusCode === 404) {
            return buildError({statusCode:404}, `Widget with id ${id} not found`);
          }
          return buildError(error, `Failed to delete widget ${id}`);
        }
      }
    
      /**
       * Get a widget by ID
       * @param id The ID of the widget to retrieve
       * @returns The widget if found
       */
      async read(ctx: HttpContext, id: string): Promise<Widget | Error> {
        try {
          const container = await this.getContainer();
    
          if(!container) {
            return buildError({statusCode:500}, "Container is not initialized");
          }
    
          const { resource } = await container.item(id, id).read<WidgetDocument>();
    
          if (!resource) {
            return buildError({statusCode:404}, `Widget with id ${id} not found`);
          }
    
          return this.documentToWidget(resource);
        } catch (error: any) {
          return buildError(error, `Failed to read widget ${id}`);
        }
      }
    
      /**
       * List all widgets with optional paging
       * @returns List of widgets
       */
      async list(ctx: HttpContext): Promise<WidgetList | Error> {
        try {
          const container = await this.getContainer();
    
          if(!container) {
            return buildError({statusCode:500}, "Container is not initialized");
          }
    
          const { resources } = await container.items
            .query({ query: "SELECT * FROM c" })
            .fetchAll();
    
          return { items: resources.map(this.documentToWidget) };
        } catch (error: any) {
          return buildError(error, "Failed to list widgets");
        }
      }
    
      /**
       * Update an existing widget
       * @param id The ID of the widget to update
       * @param body The partial widget data to update
       * @returns The updated widget
       */
      async update(
        ctx: HttpContext,
        id: string,
        body: WidgetMergePatchUpdate,
      ): Promise<Widget | Error> {
        try {
          const container = await this.getContainer();
    
          if(!container) {
            return buildError({statusCode:500}, "Container is not initialized");
          }
    
          // First check if the widget exists
          const { resource: item } = await container.item(id).read<WidgetDocument>();
          if (!item) {
            return buildError({statusCode:404}, `Widget with id ${id} not found`);
          }
    
          // Apply patch updates to the existing widget
          const updatedWidget: Widget = {
            ...item,
            ...body,
            id
          };
    
          // Replace the document in Cosmos DB
          const { resource } = await container.item(id).replace(updatedWidget);
    
          if (!resource) {
            return buildError({statusCode:500}, `Failed to update widget ${id}: No resource returned`);
          }
    
          return this.documentToWidget(resource);
        } catch (error: any) {
          return buildError(error, `Failed to update widget ${id}`);
        }
      }
    
      async analyze(ctx: HttpContext, id: string): Promise<AnalyzeResult | Error> {
        return {
          id: "mock-string",
          analysis: "mock-string",
        };
      }
    
      /**
       * Convert a Cosmos DB document to a Widget
       */
      private documentToWidget(doc: WidgetDocument): Widget {
        return Object.fromEntries(
          Object.entries(doc).filter(([key]) => !key.startsWith('_'))
        ) as Widget;
      }
    }
    
  6. ./tsp-output/server/src/index.ts 새 컨트롤러를 가져오도록 업데이트하고, Azure Cosmos DB 환경 설정을 가져온 다음, WidgetsCosmosController를 만들고 라우터에 전달합니다.

    // Generated by Microsoft TypeSpec
    
    import { WidgetsCosmosController } from "./controllers/WidgetsCosmos.js";
    
    import { createDemoServiceRouter } from "./generated/http/router.js";
    
    import express from "express";
    
    import morgan from "morgan";
    
    import { addSwaggerUi } from "./swagger-ui.js";
    
    const azureCosmosEndpoint = process.env.AZURE_COSMOS_ENDPOINT!;
    const azureCosmosDatabase = "WidgetDb";
    const azureCosmosContainer = "Widgets";
    const azureCosmosPartitionKey = "/id";
    
    const router = createDemoServiceRouter(
      new WidgetsCosmosController(
        azureCosmosEndpoint, 
        azureCosmosDatabase, 
        azureCosmosContainer, 
        azureCosmosPartitionKey)
    );
    const PORT = process.env.PORT || 3000;
    
    const app = express();
    
    app.use(morgan("dev"));
    
    const SWAGGER_UI_PATH = process.env.SWAGGER_UI_PATH || "/.api-docs";
    
    addSwaggerUi(SWAGGER_UI_PATH, app);
    
    app.use(router.expressMiddleware);
    
    app.listen(PORT, () => {
      console.log(`Server is running at http://localhost:${PORT}`);
      console.log(
        `API documentation is available at http://localhost:${PORT}${SWAGGER_UI_PATH}`,
      );
    });
    
  7. 사용 중인 터미널에서 ./tsp-output/server의 TypeScript를 JavaScript로 컴파일합니다.

    tsc
    

    이제 프로젝트는 Cosmos DB 통합을 사용하여 빌드됩니다. Azure 리소스를 만들고 프로젝트를 배포하는 배포 스크립트를 만들어 보겠습니다.

배포 인프라 만들기

Azure Developer CLI 및 Bicep 템플릿을 사용하여 반복 가능한 배포에 필요한 파일을 만듭니다.

  1. TypeSpec 프로젝트의 루트에서 배포 정의 파일을 만들고 azure.yaml 다음 원본에 붙여넣습니다.

    # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
    
    name: azure-typespec-scaffold-js
    metadata:
        template: azd-init@1.14.0
    services:
        api:
            project: ./
            host: containerapp
            language: js
            docker:
                path: Dockerfile
    pipeline:
      provider: github
    hooks:
      postprovision:
        windows:
          shell: pwsh
          run: |
            # Set environment variables for the Container App
            azd env set AZURE_COSMOS_ENDPOINT "$env:AZURE_COSMOS_ENDPOINT"
          continueOnError: false
          interactive: true
        posix:
          shell: sh
          run: |
            # Set environment variables for the Container App
            azd env set AZURE_COSMOS_ENDPOINT "$AZURE_COSMOS_ENDPOINT"
          continueOnError: false
          interactive: true
    

    이 구성은 전체 TypeSpec 프로젝트를 참조합니다.

  2. TypeSpec 프로젝트의 루트에서 Azure Container Apps에 사용할 컨테이너를 구성하는 ./Dockerfile를 만듭니다.

    # Stage 1: Build stage
    FROM node:20-alpine AS builder
    
    WORKDIR /app
    
    # Install TypeScript globally
    RUN npm install -g typescript
    
    # Copy package files first to leverage Docker layer caching
    COPY package*.json ./
    
    # Create the tsp-output/server directory structure
    RUN mkdir -p tsp-output/server
    
    # Copy server package.json 
    COPY tsp-output/server/package.json ./tsp-output/server/
    
    # Install build and dev dependencies
    RUN npm i --force --no-package-lock
    RUN cd tsp-output/server && npm install
    
    # Copy the rest of the application code
    COPY . .
    
    # Build the TypeScript code
    RUN cd tsp-output/server && tsc
    
    #---------------------------------------------------------------
    
    # Stage 2: Runtime stage
    FROM node:20-alpine AS runtime
    
    # Set NODE_ENV to production for better performance
    ENV NODE_ENV=production
    
    WORKDIR /app
    
    # Copy only the server package files
    COPY tsp-output/server/package.json ./
    
    # Install only production dependencies
    RUN npm install
    
    # Copy all necessary files from the builder stage
    # This includes the compiled JavaScript, any static assets, etc.
    COPY --from=builder /app/tsp-output/server/dist ./dist
    
    # Set default port and expose it
    ENV PORT=3000
    EXPOSE 3000
    
    # Run the application
    CMD ["node", "./dist/src/index.js"]
    
  3. TypeSpec 프로젝트의 루트에서 ./infra 디렉터리를 만듭니다.

  4. ./infra/main.bicepparam 파일을 만들고 다음을 복사하여 배포에 필요한 매개 변수를 정의합니다.

    using './main.bicep'
    
    param environmentName = readEnvironmentVariable('AZURE_ENV_NAME', 'dev')
    param location = readEnvironmentVariable('AZURE_LOCATION', 'eastus2')
    param deploymentUserPrincipalId = readEnvironmentVariable('AZURE_PRINCIPAL_ID', '')
    

    이 매개 변수 목록은 이 배포에 필요한 최소 매개 변수를 제공합니다.

  5. ./infra/main.bicep 파일을 만들고 다음을 복사하여 프로비저닝 및 배포를 위한 Azure 리소스를 정의합니다.

    metadata description = 'Bicep template for deploying a GitHub App using Azure Container Apps and Azure Container Registry.'
    
    targetScope = 'resourceGroup'
    param serviceName string = 'api'
    var databaseName = 'WidgetDb'
    var containerName = 'Widgets'
    var partitionKey = '/id'
    
    @minLength(1)
    @maxLength(64)
    @description('Name of the environment that can be used as part of naming resource convention')
    param environmentName string
    
    @minLength(1)
    @description('Primary location for all resources')
    param location string
    
    @description('Id of the principal to assign database and application roles.')
    param deploymentUserPrincipalId string = ''
    
    var resourceToken = toLower(uniqueString(resourceGroup().id, environmentName, location))
    
    var tags = {
      'azd-env-name': environmentName
      repo: 'https://github.com/typespec'
    }
    
    module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = {
      name: 'user-assigned-identity'
      params: {
        name: 'identity-${resourceToken}'
        location: location
        tags: tags
      }
    }
    
    module cosmosDb 'br/public:avm/res/document-db/database-account:0.8.1' = {
      name: 'cosmos-db-account'
      params: {
        name: 'cosmos-db-nosql-${resourceToken}'
        location: location
        locations: [
          {
            failoverPriority: 0
            locationName: location
            isZoneRedundant: false
          }
        ]
        tags: tags
        disableKeyBasedMetadataWriteAccess: true
        disableLocalAuth: true
        networkRestrictions: {
          publicNetworkAccess: 'Enabled'
          ipRules: []
          virtualNetworkRules: []
        }
        capabilitiesToAdd: [
          'EnableServerless'
        ]
        sqlRoleDefinitions: [
          {
            name: 'nosql-data-plane-contributor'
            dataAction: [
              'Microsoft.DocumentDB/databaseAccounts/readMetadata'
              'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*'
              'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*'
            ]
          }
        ]
        sqlRoleAssignmentsPrincipalIds: union(
          [
            managedIdentity.outputs.principalId
          ],
          !empty(deploymentUserPrincipalId) ? [deploymentUserPrincipalId] : []
        )
        sqlDatabases: [
          {
            name: databaseName
            containers: [
              {
                name: containerName
                paths: [
                  partitionKey
                ]
              }
            ]
          }
        ]
      }
    }
    
    module containerRegistry 'br/public:avm/res/container-registry/registry:0.5.1' = {
      name: 'container-registry'
      params: {
        name: 'containerreg${resourceToken}'
        location: location
        tags: tags
        acrAdminUserEnabled: false
        anonymousPullEnabled: true
        publicNetworkAccess: 'Enabled'
        acrSku: 'Standard'
      }
    }
    
    var containerRegistryRole = subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '8311e382-0749-4cb8-b61a-304f252e45ec'
    ) 
    
    module registryUserAssignment 'br/public:avm/ptn/authorization/resource-role-assignment:0.1.1' = if (!empty(deploymentUserPrincipalId)) {
      name: 'container-registry-role-assignment-push-user'
      params: {
        principalId: deploymentUserPrincipalId
        resourceId: containerRegistry.outputs.resourceId
        roleDefinitionId: containerRegistryRole
      }
    }
    
    module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.7.0' = {
      name: 'log-analytics-workspace'
      params: {
        name: 'log-analytics-${resourceToken}'
        location: location
        tags: tags
      }
    }
    
    module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.8.0' = {
      name: 'container-apps-env'
      params: {
        name: 'container-env-${resourceToken}'
        location: location
        tags: tags
        logAnalyticsWorkspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
        zoneRedundant: false
      }
    }
    
    module containerAppsApp 'br/public:avm/res/app/container-app:0.9.0' = {
      name: 'container-apps-app'
      params: {
        name: 'container-app-${resourceToken}'
        environmentResourceId: containerAppsEnvironment.outputs.resourceId
        location: location
        tags: union(tags, { 'azd-service-name': serviceName })
        ingressTargetPort: 3000
        ingressExternal: true
        ingressTransport: 'auto'
        stickySessionsAffinity: 'sticky'
        scaleMaxReplicas: 1
        scaleMinReplicas: 1
        corsPolicy: {
          allowCredentials: true
          allowedOrigins: [
            '*'
          ]
        }
        managedIdentities: {
          systemAssigned: false
          userAssignedResourceIds: [
            managedIdentity.outputs.resourceId
          ]
        }
        secrets: {
          secureList: [
            {
              name: 'azure-cosmos-db-nosql-endpoint'
              value: cosmosDb.outputs.endpoint
            }
            {
              name: 'user-assigned-managed-identity-client-id'
              value: managedIdentity.outputs.clientId
            }
          ]
        }
        containers: [
          {
            image: 'mcr.microsoft.com/devcontainers/typescript-node'
            name: serviceName
            resources: {
              cpu: '0.25'
              memory: '.5Gi'
            }
            env: [
              {
                name: 'AZURE_COSMOS_ENDPOINT'
                secretRef: 'azure-cosmos-db-nosql-endpoint'
              }
              {
                name: 'AZURE_CLIENT_ID'
                secretRef: 'user-assigned-managed-identity-client-id'
              }
            ]
          }
        ]
      }
    }
    
    output AZURE_COSMOS_ENDPOINT string = cosmosDb.outputs.endpoint
    output AZURE_COSMOS_DATABASE string = databaseName
    output AZURE_COSMOS_CONTAINER string = containerName
    output AZURE_COSMOS_PARTITION_KEY string = partitionKey
    
    output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer
    output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name
    

    OUTPUT 변수를 사용하면 로컬 개발과 함께 프로비전된 클라우드 리소스를 사용할 수 있습니다.

Azure에 애플리케이션 배포

Azure Container Apps를 사용하여 Azure에 이 애플리케이션을 배포할 수 있습니다.

  1. 프로젝트의 루트에 있는 터미널에서 Azure 개발자 CLI에 인증합니다.

    azd auth login
    
  2. Azure 개발자 CLI를 사용하여 Azure Container Apps에 배포:

    azd up
    
  3. 제공된 답변으로 다음 프롬프트에 응답합니다.

    • 고유한 환경 이름을 입력합니다. tsp-server-js
    • 사용할 Azure 구독 선택: 구독 선택
    • 사용할 Azure 위치 선택: 가까운 위치 선택
    • 사용할 리소스 그룹 선택: 새 리소스 그룹 만들기 선택
    • 새 리소스 그룹의 이름을 입력합니다. 제공된 기본값을 적용합니다.
  4. 배포가 완료될 때까지 기다립니다. 응답에는 다음과 유사한 정보가 포함됩니다.

    Deploying services (azd deploy)
    
      (✓) Done: Deploying service api
      - Endpoint: https://container-app-123.ambitiouscliff-456.centralus.azurecontainerapps.io/
    
    
    SUCCESS: Your up workflow to provision and deploy to Azure completed in 6 minutes 32 seconds.
    

브라우저에서 애플리케이션 사용

배포되면 다음을 수행할 수 있습니다.

  1. 콘솔에서 URL을 Endpoint 선택하여 브라우저에서 엽니다.
  2. Swagger UI를 사용하기 위해 엔드포인트에 경로를 /.api-docs추가합니다.
  3. 이제 각 메서드에서 Try it 기능을 사용하여 API를 통해 위젯을 만들고, 읽고, 업데이트하고, 삭제합니다.

애플리케이션 확장

이제 전체 엔드-엔드 프로세스가 작동했으므로 API를 계속 빌드합니다.

  • 에 API 및 API 계층 기능을 더 추가하려면 TypeSpec 언어 에 대해 자세히 알아봅니다 ./main.tsp.
  • 방출기를 더 추가하고 해당 매개변수를 ./tspconfig.yaml에서 구성합니다.
  • TypeSpec 파일에 추가 기능을 추가하면 서버 프로젝트의 소스 코드를 사용하여 이러한 변경 내용을 지원합니다.
  • Azure ID에서 암호 없는 인증 을 계속 사용합니다.

자원을 정리하세요

이 빠른 시작을 완료하면 Azure 리소스를 제거할 수 있습니다.

azd down

또는 Azure Portal에서 직접 리소스 그룹을 삭제합니다.

Troubleshooting

포트 3000이 이미 사용 중입니다.

오류:listen EADDRINUSE: address already in use :::3000

Solution:

  1. 포트 3000 netstat -tulpn | grep 3000 (macOS/Linux) 또는 netstat -ano | findstr :3000 (Windows)를 사용하여 프로세스를 식별합니다.
  2. 해당 프로세스를 중지하거나 PORT 환경 변수 PORT=3001 npm start를 변경합니다.

Node.js 버전 불일치

오류:tsp: command not found 또는 npm ERR! The engine "node" is incompatible

Solution:

  1. Node.js 버전을 node --version확인합니다.
  2. LTS 버전인지 확인합니다. nodejs.org 또는 패키지 관리자에서 업데이트합니다.
  3. npm 캐시 지우기: npm cache clean --force다음을 다시 설치합니다 npm install -g @typespec/compiler.

@typespec/http-server-js 방출기를 찾을 수 없음

오류:Error: Cannot find module '@typespec/http-server-js'

Solution:

  1. 이미터가 tspconfig.yaml 안에 emit: 나열되어 있는지 확인합니다.
  2. 다시 컴파일: tsp compile ..
  3. 오류가 지속되면 종속성을 다시 설치합니다. cd ./tsp-output/server && npm install

생성된 서버의 TypeScript 컴파일 오류

오류:error TS2322: Type 'X' is not assignable to type 'Y' 생성된 파일에서

Solution:

  1. main.tsp tsp compile .을 수정한 후 cd ./tsp-output/server && tsc에 따라 서버를 다시 생성하십시오.
  2. 사용자 지정 컨트롤러 코드가 디렉터리 외부 에 ./generated 있는지 확인합니다. 생성된 파일을 직접 편집하지 마세요.

시작 시 Express.js 서버 크래시

오류:listen EADDRINUSE 또는 Error: Cannot find module '@azure/cosmos'

Solution:

  1. 누락된 종속성 설치: cd ./tsp-output/server && npm install @azure/cosmos @azure/identity.
  2. 포트 충돌의 경우 PORT를 index.ts 변경하거나 사용합니다 PORT=3001 npm start.

다음 단계