다음을 통해 공유


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

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

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

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

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 문서
      • C# 서버 스텁 코드

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

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

    Run tsp compile . to build the project.
    
    Please review the following messages from emitters:
      @typespec/http-server-csharp: 
    
            Generated ASP.Net services require dotnet 9:
            https://dotnet.microsoft.com/download 
    
            Create an ASP.Net service project for your TypeSpec:
            > npx hscs-scaffold . --use-swaggerui --overwrite
    
            More information on getting started:
            https://aka.ms/tsp/hscs/start
    
  7. 프로젝트를 컴파일합니다.

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

    • schema
    • server
  9. ./tsp-output/schema/openapi.yaml 파일을 엽니다. ./main.tsp의 몇 줄이 여러분을 위해 200줄이 넘는 OpenApi 사양을 생성한 것을 주목하세요.

  10. ./tsp-output/server/aspnet 폴더를 엽니다. 스캐폴드된 .NET 파일에는 다음이 포함됩니다.

    • ./generated/operations/IWidgets.cs 은 위젯 메서드에 대한 인터페이스를 정의합니다.
    • ./generated/controllers/WidgetsController.cs 는 Widgets 메서드에 통합을 구현합니다. 여기서 비즈니스 논리를 배치합니다.
    • ./generated/models 은 위젯 API에 대한 모델을 정의합니다.

TypeSpec 방출기 구성

TypeSpec 파일을 사용하여 API 서버 생성을 구성합니다.

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

    emit:
      - "@typespec/openapi3"
      - "@typespec/http-server-csharp"
    options:
      "@typespec/openapi3":
        emitter-output-dir: "{cwd}/server/wwwroot"
        openapi-versions:
          - 3.1.0
      "@typespec/http-server-csharp":
        emitter-output-dir: "{cwd}/server/"
        use-swaggerui: true
        overwrite: true
        emit-mocks: "mocks-and-project-files"
    

    이 구성은 완전히 생성된 .NET API 서버에 필요한 몇 가지 변경 사항을 투영합니다.

    • emit-mocks: 서버에 필요한 모든 프로젝트 파일을 만듭니다.
    • use-swaggerui: 브라우저에 친숙한 방식으로 API를 사용할 수 있도록 Swagger UI를 통합합니다.
    • emitter-output-dir: 서버 생성 및 OpenApi 사양 생성 모두에 대한 출력 디렉터리를 설정합니다.
    • 모든 항목을 ./server에 생성합니다.
  2. 프로젝트를 다시 컴파일합니다.

    tsp compile .
    
  3. /server 디렉터리로 변경합니다.

    cd server
    
  4. 아직 없는 경우 기본 개발자 인증서를 만듭니다.

    dotnet dev-certs https
    
  5. 프로젝트를 실행합니다.

    dotnet run
    

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

  6. 브라우저를 열고 Swagger UI 경로를 /swagger추가합니다.

    위젯 API가 있는 Swagger UI를 보여 주는 스크린샷

  7. 기본 TypeSpec API와 서버가 모두 작동합니다.

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

생성된 서버의 프로젝트 구조에는 .NET 컨트롤러 기반 API 서버, 프로젝트를 빌드하기 위한 .NET 파일 및 Azure 통합을 위한 미들웨어가 포함됩니다.

├── appsettings.Development.json
├── appsettings.json
├── docs
├── generated
├── mocks
├── Program.cs
├── Properties
├── README.md
├── ServiceProject.csproj
└── wwwroot
  • 비즈니스 논리를 추가합니다. 이 예제에서는 파일로 시작합니다 ./server/mocks/Widget.cs . 생성된 Widget.cs는 기본 메서드를 제공합니다.
  • 서버 업데이트: 특정 서버 구성을 추가합니다 ./program.cs.
  • OpenApi 사양을 사용합니다. TypeSpec은 OpenApi3.json 파일을 ./server/wwwroot 파일로 생성하여 개발 중에 Swagger UI에서 사용할 수 있도록 했습니다. 사양에 대한 UI를 제공합니다. REST 클라이언트 또는 웹 프런트 엔드와 같은 요청 메커니즘을 제공하지 않고도 API와 상호 작용할 수 있습니다.

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

이제 기본 위젯 API 서버가 작동하므로 영구 데이터 저장소에 대한 Azure Cosmos DB 와 함께 작동하도록 서버를 업데이트합니다.

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

    dotnet add package Microsoft.Azure.Cosmos
    
  2. Azure ID 라이브러리를 추가하여 Azure에 인증합니다.

    dotnet add package Azure.Identity
    
  3. Cosmos DB 통합 설정의 ./server/ServiceProject.csproj을(를) 업데이트하세요.

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        ... existing settings ...
        <EnableSdkContainerSupport>true</EnableSdkContainerSupport>
      </PropertyGroup>
      <ItemGroup>
        ... existing settings ...
        <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
      </ItemGroup>
    </Project>
    
    • EnableSdkContainerSupport를 사용하면 Dockerfile을 작성하지 않고 .NET SDK의 기본 제공 컨테이너 빌드 지원(dotnet publish ––container)을 사용할 수 있습니다.
    • Newtonsoft.Json은 Cosmos DB SDK가 .NET 개체를 JSON으로 변환하는 데 사용하는 Json .NET 직렬 변환기를 추가합니다.
  4. Cosmos DB 등록을 관리하기 위해 새 등록 파일을 ./azure/CosmosDbRegistration 만듭니다.

    using Microsoft.Azure.Cosmos;
    using Microsoft.Extensions.Configuration;
    using System;
    using System.Threading.Tasks;
    using Azure.Identity;
    using DemoService;
    
    namespace WidgetService.Service
    {
        /// <summary>
        /// Registration class for Azure Cosmos DB services and implementations
        /// </summary>
        public static class CosmosDbRegistration
        {
            /// <summary>
            /// Registers the Cosmos DB client and related services for dependency injection
            /// </summary>
            /// <param name="builder">The web application builder</param>
            public static void RegisterCosmosServices(this WebApplicationBuilder builder)
            {
                // Register the HttpContextAccessor for accessing the HTTP context
                builder.Services.AddHttpContextAccessor();
    
                // Get configuration settings
                var cosmosEndpoint = builder.Configuration["Configuration:AzureCosmosDb:Endpoint"];
    
                // Validate configuration
                ValidateCosmosDbConfiguration(cosmosEndpoint);
    
                // Configure Cosmos DB client options
                var cosmosClientOptions = new CosmosClientOptions
                {
                    SerializerOptions = new CosmosSerializationOptions
                    {
                        PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
                    },
                    ConnectionMode = ConnectionMode.Direct
                };
    
                builder.Services.AddSingleton(serviceProvider =>
                {
                    var credential = new DefaultAzureCredential();
    
                    // Create Cosmos client with token credential authentication
                    return new CosmosClient(cosmosEndpoint, credential, cosmosClientOptions);
                });
    
                // Initialize Cosmos DB if needed
                builder.Services.AddHostedService<CosmosDbInitializer>();
    
                // Register WidgetsCosmos implementation of IWidgets
                builder.Services.AddScoped<IWidgets, WidgetsCosmos>();
            }
    
            /// <summary>
            /// Validates the Cosmos DB configuration settings
            /// </summary>
            /// <param name="cosmosEndpoint">The Cosmos DB endpoint</param>
            /// <exception cref="ArgumentException">Thrown when configuration is invalid</exception>
            private static void ValidateCosmosDbConfiguration(string cosmosEndpoint)
            {
                if (string.IsNullOrEmpty(cosmosEndpoint))
                {
                    throw new ArgumentException("Cosmos DB Endpoint must be specified in configuration");
                }
            }
        }
    }
    

    엔드포인트에 대한 환경 변수를 확인합니다.

    var cosmosEndpoint = builder.Configuration["Configuration:AzureCosmosDb:Endpoint"];
    
  5. 영구 저장소에 대한 Azure Cosmos DB와 통합하는 비즈니스 논리를 제공하기 위해 새 위젯 클래스 ./azure/WidgetsCosmos.cs 를 만듭니다.

    using System;
    using System.Net;
    using System.Threading.Tasks;
    using Microsoft.Azure.Cosmos;
    using Microsoft.Extensions.Logging;
    using System.Collections.Generic;
    using System.Linq;
    
    // Use generated models and operations
    using DemoService;
    
    namespace WidgetService.Service
    {
        /// <summary>
        /// Implementation of the IWidgets interface that uses Azure Cosmos DB for persistence
        /// </summary>
        public class WidgetsCosmos : IWidgets
        {
            private readonly CosmosClient _cosmosClient;
            private readonly ILogger<WidgetsCosmos> _logger;
            private readonly IHttpContextAccessor _httpContextAccessor;
            private readonly string _databaseName = "WidgetDb";
            private readonly string _containerName = "Widgets";
    
            /// <summary>
            /// Initializes a new instance of the WidgetsCosmos class.
            /// </summary>
            /// <param name="cosmosClient">The Cosmos DB client instance</param>
            /// <param name="logger">Logger for diagnostic information</param>
            /// <param name="httpContextAccessor">Accessor for the HTTP context</param>
            public WidgetsCosmos(
                CosmosClient cosmosClient,
                ILogger<WidgetsCosmos> logger,
                IHttpContextAccessor httpContextAccessor)
            {
                _cosmosClient = cosmosClient;
                _logger = logger;
                _httpContextAccessor = httpContextAccessor;
            }
    
            /// <summary>
            /// Gets a reference to the Cosmos DB container for widgets
            /// </summary>
            private Container WidgetsContainer => _cosmosClient.GetContainer(_databaseName, _containerName);
    
            /// <summary>
            /// Lists all widgets in the database
            /// </summary>
            /// <returns>Array of Widget objects</returns>
            public async Task<WidgetList> ListAsync()
            {
                try
                {
                    var queryDefinition = new QueryDefinition("SELECT * FROM c");
                    var widgets = new List<Widget>();
    
                    using var iterator = WidgetsContainer.GetItemQueryIterator<Widget>(queryDefinition);
                    while (iterator.HasMoreResults)
                    {
                        var response = await iterator.ReadNextAsync();
                        widgets.AddRange(response.ToList());
                    }
    
                    // Create and return a WidgetList instead of Widget[]
                    return new WidgetList
                    {
                        Items = widgets.ToArray()
                    };
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error listing widgets from Cosmos DB");
                    throw new Error(500, "Failed to retrieve widgets from database");
                }
            }
    
            /// <summary>
            /// Retrieves a specific widget by ID
            /// </summary>
            /// <param name="id">The ID of the widget to retrieve</param>
            /// <returns>The retrieved Widget</returns>
            public async Task<Widget> ReadAsync(string id)
            {
                try
                {
                    var response = await WidgetsContainer.ReadItemAsync<Widget>(
                        id, new PartitionKey(id));
    
                    return response.Resource;
                }
                catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
                {
                    _logger.LogWarning("Widget with ID {WidgetId} not found", id);
                    throw new Error(404, $"Widget with ID '{id}' not found");
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error reading widget {WidgetId} from Cosmos DB", id);
                    throw new Error(500, "Failed to retrieve widget from database");
                }
            }
            /// <summary>
            /// Creates a new widget from the provided Widget object
            /// </summary>
            /// <param name="body">The Widget object to store in the database</param>
            /// <returns>The created Widget</returns>
            public async Task<Widget> CreateAsync(Widget body)
            {
                try
                {
                    // Validate the Widget
                    if (body == null)
                    {
                        throw new Error(400, "Widget data cannot be null");
                    }
    
                    if (string.IsNullOrEmpty(body.Id))
                    {
                        throw new Error(400, "Widget must have an Id");
                    }
    
                    if (body.Color != "red" && body.Color != "blue")
                    {
                        throw new Error(400, "Color must be 'red' or 'blue'");
                    }
    
                    // Save the widget to Cosmos DB
                    var response = await WidgetsContainer.CreateItemAsync(
                        body, new PartitionKey(body.Id));
    
                    _logger.LogInformation("Created widget with ID {WidgetId}", body.Id);
                    return response.Resource;
                }
                catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
                {
                    _logger.LogError(ex, "Widget with ID {WidgetId} already exists", body.Id);
                    throw new Error(409, $"Widget with ID '{body.Id}' already exists");
                }
                catch (Exception ex) when (!(ex is Error))
                {
                    _logger.LogError(ex, "Error creating widget in Cosmos DB");
                    throw new Error(500, "Failed to create widget in database");
                }
            }
    
            /// <summary>
            /// Updates an existing widget with properties specified in the patch document
            /// </summary>
            /// <param name="id">The ID of the widget to update</param>
            /// <param name="body">The WidgetMergePatchUpdate object containing properties to update</param>
            /// <returns>The updated Widget</returns>
            public async Task<Widget> UpdateAsync(string id, TypeSpec.Http.WidgetMergePatchUpdate body)
            {
                try
                {
                    // Validate input parameters
                    if (body == null)
                    {
                        throw new Error(400, "Update data cannot be null");
                    }
    
                    if (body.Color != null && body.Color != "red" && body.Color != "blue")
                    {
                        throw new Error(400, "Color must be 'red' or 'blue'");
                    }
    
                    // First check if the item exists
                    Widget existingWidget;
                    try
                    {
                        var response = await WidgetsContainer.ReadItemAsync<Widget>(
                            id, new PartitionKey(id));
                        existingWidget = response.Resource;
                    }
                    catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
                    {
                        _logger.LogWarning("Widget with ID {WidgetId} not found for update", id);
                        throw new Error(404, $"Widget with ID '{id}' not found");
                    }
    
                    // Apply the patch updates only where properties are provided
                    bool hasChanges = false;
    
                    if (body.Weight.HasValue)
                    {
                        existingWidget.Weight = body.Weight.Value;
                        hasChanges = true;
                    }
    
                    if (body.Color != null)
                    {
                        existingWidget.Color = body.Color;
                        hasChanges = true;
                    }
    
                    // Only perform the update if changes were made
                    if (hasChanges)
                    {
                        // Use ReplaceItemAsync for the update
                        var updateResponse = await WidgetsContainer.ReplaceItemAsync(
                            existingWidget, id, new PartitionKey(id));
    
                        _logger.LogInformation("Updated widget with ID {WidgetId}", id);
                        return updateResponse.Resource;
                    }
    
                    // If no changes, return the existing widget
                    _logger.LogInformation("No changes to apply for widget with ID {WidgetId}", id);
                    return existingWidget;
                }
                catch (Error)
                {
                    // Rethrow Error exceptions
                    throw;
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error updating widget {WidgetId} in Cosmos DB", id);
                    throw new Error(500, "Failed to update widget in database");
                }
            }
    
            /// <summary>
            /// Deletes a widget by its ID
            /// </summary>
            /// <param name="id">The ID of the widget to delete</param>
            public async Task DeleteAsync(string id)
            {
                try
                {
                    await WidgetsContainer.DeleteItemAsync<Widget>(id, new PartitionKey(id));
                    _logger.LogInformation("Deleted widget with ID {WidgetId}", id);
                }
                catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
                {
                    _logger.LogWarning("Widget with ID {WidgetId} not found for deletion", id);
                    throw new Error(404, $"Widget with ID '{id}' not found");
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error deleting widget {WidgetId} from Cosmos DB", id);
                    throw new Error(500, "Failed to delete widget from database");
                }
            }
    
            /// <summary>
            /// Analyzes a widget by ID and returns a simplified analysis result
            /// </summary>
            /// <param name="id">The ID of the widget to analyze</param>
            /// <returns>An AnalyzeResult containing the analysis of the widget</returns>
            public async Task<AnalyzeResult> AnalyzeAsync(string id)
            {
                try
                {
                    // First retrieve the widget from the database
                    Widget widget;
                    try
                    {
                        var response = await WidgetsContainer.ReadItemAsync<Widget>(
                            id, new PartitionKey(id));
                        widget = response.Resource;
                    }
                    catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
                    {
                        _logger.LogWarning("Widget with ID {WidgetId} not found for analysis", id);
                        throw new Error(404, $"Widget with ID '{id}' not found");
                    }
    
                    // Create the analysis result
                    var result = new AnalyzeResult
                    {
                        Id = widget.Id,
                        Analysis = $"Weight: {widget.Weight}, Color: {widget.Color}"
                    };
    
                    _logger.LogInformation("Analyzed widget with ID {WidgetId}", id);
                    return result;
                }
                catch (Error)
                {
                    // Rethrow Error exceptions
                    throw;
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error analyzing widget {WidgetId} from Cosmos DB", id);
                    throw new Error(500, "Failed to analyze widget from database");
                }
            }
        }
    }
    
  6. Azure에 ./server/services/CosmosDbInitializer.cs 인증할 파일을 만듭니다.

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.Azure.Cosmos;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Logging;
    
    namespace WidgetService.Service
    {
        /// <summary>
        /// Hosted service that initializes Cosmos DB resources on application startup
        /// </summary>
        public class CosmosDbInitializer : IHostedService
        {
            private readonly CosmosClient _cosmosClient;
            private readonly ILogger<CosmosDbInitializer> _logger;
            private readonly IConfiguration _configuration;
            private readonly string _databaseName;
            private readonly string _containerName = "Widgets";
    
            public CosmosDbInitializer(CosmosClient cosmosClient, ILogger<CosmosDbInitializer> logger, IConfiguration configuration)
            {
                _cosmosClient = cosmosClient;
                _logger = logger;
                _configuration = configuration;
                _databaseName = _configuration["CosmosDb:DatabaseName"] ?? "WidgetDb";
            }
    
            public async Task StartAsync(CancellationToken cancellationToken)
            {
                _logger.LogInformation("Ensuring Cosmos DB database and container exist...");
    
                try
                {
                    // Create database if it doesn't exist
                    var databaseResponse = await _cosmosClient.CreateDatabaseIfNotExistsAsync(
                        _databaseName,
                        cancellationToken: cancellationToken);
    
                    _logger.LogInformation("Database {DatabaseName} status: {Status}", _databaseName,
                        databaseResponse.StatusCode == System.Net.HttpStatusCode.Created ? "Created" : "Already exists");
    
                    // Create container if it doesn't exist (using id as partition key)
                    var containerResponse = await databaseResponse.Database.CreateContainerIfNotExistsAsync(
                        new ContainerProperties
                        {
                            Id = _containerName,
                            PartitionKeyPath = "/id"
                        },
                        throughput: 400, // Minimum RU/s
                        cancellationToken: cancellationToken);
    
                    _logger.LogInformation("Container {ContainerName} status: {Status}", _containerName,
                        containerResponse.StatusCode == System.Net.HttpStatusCode.Created ? "Created" : "Already exists");
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error initializing Cosmos DB");
                    throw;
                }
            }
    
            public Task StopAsync(CancellationToken cancellationToken)
            {
                return Task.CompletedTask;
            }
        }
    }
    
  7. ./server/program.cs Cosmos DB를 사용하도록 업데이트하고 Swagger UI를 프로덕션 배포에 사용할 수 있도록 합니다. 전체 파일에서 복사:

    // Generated by @typespec/http-server-csharp
    // <auto-generated />
    #nullable enable
    
    using TypeSpec.Helpers;
    using WidgetService.Service;
    
    var builder = WebApplication.CreateBuilder(args);
    
    // Add services to the container.
    builder.Services.AddControllersWithViews(options =>
    {
        options.Filters.Add<HttpServiceExceptionFilter>();
    });
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen();
    
    // Replace original registration with the Cosmos DB one
    CosmosDbRegistration.RegisterCosmosServices(builder);
    
    var app = builder.Build();
    
    // Configure the HTTP request pipeline.
    if (!app.Environment.IsDevelopment())
    {
        app.UseExceptionHandler("/Home/Error");
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }
    
    // Swagger UI is always available
    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.DocumentTitle = "TypeSpec Generated OpenAPI Viewer";
        c.SwaggerEndpoint("/openapi.yaml", "TypeSpec Generated OpenAPI Docs");
        c.RoutePrefix = "swagger";
    });
    
    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.Use(async (context, next) =>
    {
        context.Request.EnableBuffering();
        await next();
    });
    
    app.MapGet("/openapi.yaml", async (HttpContext context) =>
    {
        var externalFilePath = "wwwroot/openapi.yaml"; 
        if (!File.Exists(externalFilePath))
        {
            context.Response.StatusCode = StatusCodes.Status404NotFound;
            await context.Response.WriteAsync("OpenAPI spec not found.");
            return;
        }
        context.Response.ContentType = "application/json";
        await context.Response.SendFileAsync(externalFilePath);
    });
    
    app.UseRouting();
    app.UseAuthorization();
    
    app.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
    
    app.Run();
    
  8. 프로젝트를 빌드합니다.

    dotnet build
    

    이제 프로젝트는 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-dotnet
    metadata:
        template: azd-init@1.14.0
    services:
        api:
            project: ./server
            host: containerapp
            language: dotnet
    pipeline:
      provider: github
    

    이 구성은 생성된 프로젝트 위치(./server)를 참조합니다. 에 ./tspconfig.yaml 지정된 위치와 일치하는지 확인합니다 ./azure.yaml.

  2. TypeSpec 프로젝트의 루트에서 ./infra 디렉터리를 만듭니다.

  3. ./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', '')
    

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

  4. ./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'
    
    @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: [
                  '/id'
                ]
              }
            ]
          }
        ]
      }
    }
    
    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: 8080
        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/dotnet/samples:aspnetapp-9.0'
            name: serviceName
            resources: {
              cpu: '0.25'
              memory: '.5Gi'
            }
            env: [
              {
                name: 'CONFIGURATION__AZURECOSMOSDB__ENDPOINT'
                secretRef: 'azure-cosmos-db-nosql-endpoint'
              }
              {
                name: 'AZURE_CLIENT_ID'
                secretRef: 'user-assigned-managed-identity-client-id'
              }
            ]
          }
        ]
      }
    }
    
    output CONFIGURATION__AZURECOSMOSDB__ENDPOINT string = cosmosDb.outputs.endpoint
    output CONFIGURATION__AZURECOSMOSDB__DATABASENAME string = databaseName
    output CONFIGURATION__AZURECOSMOSDB__CONTAINERNAME string = containerName
    
    output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer
    

    출력 변수를 사용하면 로컬 개발에서 프로비전된 클라우드 리소스를 사용할 수 있습니다.

  5. containerAppsApp 태그는 serviceName 변수(파일 맨 위에 api로 설정됨) 및 api에 지정된 ./azure.yaml 변수를 사용합니다. 이 연결은 Azure Developer CLI에 .NET 프로젝트를 Azure Container Apps 호스팅 리소스에 배포할 위치를 알려줍니다.

    ...bicep...
    
    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 })                    <--------- `API`
    
    ...bicep..
    

프로젝트 구조

최종 프로젝트 구조에는 TypeSpec API 파일, Express.js 서버 및 Azure 배포 파일이 포함됩니다.

├── infra
├── tsp-output
├── .gitignore
├── .azure.yaml
├── Dockerfile
├── main.tsp
├── package-lock.json
├── package.json
├── tspconfig.yaml
Area 파일/디렉터리
TypeSpec main.tsp, tspconfig.yaml
Express.js 서버 ./tsp-output/server/(생성된 파일(예: controllers/, models/ServiceProject.csproj)을 포함합니다.
Azure 개발자 CLI 배포 ./azure.yaml, ./infra/

Azure에 애플리케이션 배포

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

  1. Azure 개발자 CLI에 인증:

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

    azd up
    

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

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

  1. Swagger UI에 액세스하여 /swagger에서 API를 테스트합니다.
  2. 이제 각 API에서 Try it 기능을 사용하여 API를 통해 위젯을 만들고, 읽고, 업데이트하고, 삭제합니다.

애플리케이션 확장

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

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

자원을 정리하세요

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

azd down

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

다음 단계