본문 바로가기

iOS/Swift

Swift OpenAPI Generator 사용해보기

💫 OpenAPI?

OpenAPI는 RESTful API Spec을 yaml이나 json으로 표현하는 표준화된 문서이다. 표현된 yaml이나 json을 이용하면 개발자가 생성한 API들을 문서화하거나 코드를 자동으로 생성하여 사용할 수 있다. 대표적으로 Swagger UI가 있다.

💫 Swift OpenAPI Generator

이미 기존에도 OpenAPI를 활용하여 코드를 생성해주는 라이브러리나 플러그인들이 있었지만 지금 소개하는 Swift OpenAPI Generator는 Apple에서 개발하고 Xcode와 최적화가 잘 되어있다고 한다.

 

깃허브 https://github.com/apple/swift-openapi-generator

 

GitHub - apple/swift-openapi-generator: Generate Swift client and server code from an OpenAPI document.

Generate Swift client and server code from an OpenAPI document. - apple/swift-openapi-generator

github.com

 

공식문서 https://www.swift.org/blog/introducing-swift-openapi-generator/

 

Introducing Swift OpenAPI Generator

We’re excited to announce a set of open source libraries designed to help both client and server developers streamline their workflow around HTTP communication using the industry‑standard OpenAPI specification.

www.swift.org

💫 실습해보기

프로젝트 생성 및 라이브러리 설치

OpenAPIGenerator라는 프로젝트를 생성하고 Swift OpenAPI Generator사용에 필요한 3개의 라이브러리를 설치한다.

https://github.com/apple/swift-openapi-generator
https://github.com/apple/swift-openapi-runtime
https://github.com/apple/swift-openapi-urlsession

플러그인 추가

OpenAPIGenerator 플러그인을 Build Phases에 추가한다.

문서 작성

플러그인을 통해 OpenAPI를 생성하기 위한 2개의 필수 파일이 필요하다. 하나는 설정을 관리하는 openapi-generator-config.yaml 다른 하나는 문서화된 openapi.yaml 가 필요하다.

2개의 파일 생성

openapi-generator-config.yaml

generate:
  - types
  - client

 

설정 파일은 간단하게 작성

openapi.yaml

openapi에는 내가 다룰 API문서를 작성하면 된다. 이번 예제로는 https://api.sampleapis.com/coffee/hot 커피리스트 샘플 api를 활용할 예정이다. 아래는 학습용으로 예제에 필요없는 코드들이 작성되어 있으니 예제를 실습하고자 한다면 단락 마지막에 작성된 최종코드를 참고하면 된다.

1. openapi 버전 작성

openapi: '3.0.3'

github를 보면 현재 지원하는 OpenAPI버전은 3.0과 3.1이 있다. 이번 예제에서는 3.0.3 버전을 사용할 예정이다.

 

2. info 작성

info:
  title: HotCoffees
  version: 1.0.0

info는 title과 version으로 자유롭게 작성

 

3. servers 작성

servers:
  - url: https://example.com/api
    description: Example service deployment.
  - url: http://127.0.0.1:8080/api
    description: Localhost deployment.
  - url: https://api.sampleapis.com
    description: 샘플 API의 Base URL

위 예에서는 3개의 server를 추가하였고 url은 서버주소, description은 설명을 자유롭게 작성하면된다.

 

4. components 작성

components는 API의 리퀘스트/리스폰스 객체를 작성하는 부분이다. 그러므로 우선 우리가 진행할 커피 리스트의 예제를 확인해보자.

[
  {
    "title": "Black Coffee",
    "description": "Svart kaffe är så enkelt som det kan bli med malda kaffebönor dränkta i hett vatten, serverat varmt. Och om du vill låta fancy kan du kalla svart kaffe med sitt rätta namn: café noir.",
    "ingredients": [
      "Coffee"
    ],
    "image": "https://images.unsplash.com/photo-1494314671902-399b18174975?auto=format&fit=crop&q=80&w=1887&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    "id": 1
  },
  ...[중략]
]

API를 요청해보면 위와 같은 객체 리스트로 반환한다. 이제 이를 참고하여 components를 작성해보자.

components:
  schemas:
    Coffee:
      type: object
      properties:
        title:
          type: string
          example: 커피 타이틀
        description:
          type: string
        ingredients:
          type: array
          items:
            type: string
        image:
          type: string
          example: "https://images.unsplash.com/photo-1494314671902-399b18174975?auto=format&fit=crop&q=80&w=1887&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
        id:
          type: integer
      required:
        - title
        - description
        - ingredients
        - id

Coffee라는 object 타입을 생성하고 properties는 리스폰스에 맞게 title, description, ingredients, image, id를 작성해주고 각각의 타입에 맞게 작성한다. example은 옵션으로 작성을 해도되고 안해도 된다.

그 다음 required는 위 properties의 필수값으로 image만 제외하고 모두 필수값으로 작성해보았다.

 

5. paths 작성

paths:
  /greet:
    get:
      operationId: getGreeting
      parameters:
        - name: name
          required: false
          in: query
          description: The name used in the returned greeting.
          schema:
            type: string
      responses:
        '200':
          description: A success response with a greeting.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Greeting'
  /coffee/hot:
    get:
      operationId: getHotCoffees
      responses:
        '200':
          description: 커피 리스트를 불러왔습니다.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Coffee'

파라미터가 필요하고 object를 리턴하는 /greet 예제와 파라미터가 없고 array object를 리턴하는 /coffee/hot 예제이다.

responses의 schema처럼 $ref: 를 사용하여 이전에 작성한 components에서 객체 참조를 불러와서 사용할 수도 있고 parameters의 schema처럼 간단하게 직접 입력할 수도있다.

 

전체 코드

openapi: '3.0.3'
info:
  title: HotCoffees
  version: 1.0.0
servers:
  - url: https://api.sampleapis.com
    description: 샘플 API의 Base URL
components:
  schemas:
    Coffee:
      type: object
      properties:
        title:
          type: string
          example: 커피 타이틀
        description:
          type: string
        ingredients:
          type: array
          items:
            type: string
        image:
          type: string
          example: "https://images.unsplash.com/photo-1494314671902-399b18174975?auto=format&fit=crop&q=80&w=1887&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
        id:
          type: integer
      required:
        - title
        - description
        - ingredients
        - id
paths:
  /coffee/hot:
    get:
      operationId: getHotCoffees
      responses:
        '200':
          description: 커피 리스트를 불러왔습니다.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Coffee'

프로젝트에 적용

WebService 생성

import Foundation
import OpenAPIURLSession

final class WebService<C: APIProtocol> {
    private let client: C
    
    init(client: C) {
        self.client = client
    }
    
    init() where C == Client {
        self.client = Client(
            serverURL: try! Servers.server1(),
            transport: URLSessionTransport()
        )
    }
    
    func getHotCoffees() async -> [Components.Schemas.Coffee] {
        do {
            let response = try await client.getHotCoffees()
            
            switch response {
            case .ok(let okResponse):
                switch okResponse.body {
                case .json(let coffees):
                    return coffees
                }
            case .undocumented(statusCode: let statusCode, _):
                print("Undocumented Error: \(statusCode)")
                return []
            }
        } catch {
            print("error: \(error.localizedDescription)")
            return []
        }
    }
}

 

OpenAPI Generator에 의해 생성된 코드를 사용하여 WebService를 구현한다. 생성된 코드의 설명은 아래와 같다.

- types

APIProtocol: openapi.yaml에서 작성한 paths의 각각의 path로 UseCases로 생각하면 된다.

Servers: openapi.yaml에서 작성한 servers부분으로 첫번째 URL은 server1(), 두번째는 server2() 이런식으로 불러오면 된다.

Components, Schemas: openapi.yaml에서 작성한 components, schemas부분으로 각각의 Model로 생각하면 된다.

- client

Client: OpenAPI에서 생성해준 HTTP통신 클라이언트

MockClient 생성

struct MockClient: APIProtocol {
    func getHotCoffees(_ input: Operations.getHotCoffees.Input) async throws -> Operations.getHotCoffees.Output {
        let postsBody = [
            Components.Schemas.Coffee(title: "아메리카노", description: "아메 아메 아메리카노", ingredients: ["커피"], image: "https://images.unsplash.com/photo-1494314671902-399b18174975?auto=format&fit=crop&q=80&w=1887&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", id: 1),
            Components.Schemas.Coffee(title: "핫초코", description: "맛있는 핫-초코", ingredients: [], id: 2)
        ]
        return .ok(Operations.getHotCoffees.Output.Ok(body: .json(postsBody)))
    }
}

자동 생성된 client는 실제 HTTP통신을 하므로 테스트를 위해 APIProtocol을 상속받은 MockClient를 구현하여 사용한다.

UI

import SwiftUI
import OpenAPIURLSession

// Coffee extensions
extension Components.Schemas.Coffee: Identifiable { }

extension Components.Schemas.Coffee {
    var imageURL: URL? {
        URL(string: image ?? "")
    }
}

struct ContentView: View {
    @State private var coffees = [Components.Schemas.Coffee]()
    
    var body: some View {
        List(coffees) { coffee in
            HStack {
                Group {
                    if let imageURL = coffee.imageURL {
                        AsyncImage(url: imageURL) { image in
                            image
                                .resizable()
                        } placeholder: {
                            ProgressView()
                        }
                    } else {
                        Image(systemName: "cup.and.saucer.fill")
                            .scaleEffect(2)
                    }
                }
                .frame(width: 100, height: 100)
                
                VStack(alignment: .leading) {
                    Text(coffee.title)
                        .font(.title2)
                        .bold()
                    
                    Text(coffee.description)
                        .lineLimit(2)
                    
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack {
                            ForEach(coffee.ingredients, id: \.self) { ingredient in
                                Text(ingredient)
                                    .padding(.vertical, 4)
                                    .padding(.horizontal, 8)
                                    .overlay(
                                        RoundedRectangle(cornerRadius: 5)
                                            .stroke(.gray)
                                    )
                            }
                        }
                        .padding(1)
                    }
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding()
            .background(.thinMaterial)
        }
        .task {
            // 테스트 클라이언트
            coffees = await WebService(client: MockClient()).getHotCoffees()
            
            // 실제 클라이언트
//            coffees = await WebService().getHotCoffees()
        }
    }
}

테스트에서는 MockClient()를 WebService에 주입하여 사용하고 프로덕트에서는 기본 생성자를 사용한다.

좌) 테스트, 우) 프로덕트

 

이로써 쉽게 테스트 가능하고 문서화까지 되는 Swift OpenAPI Generator에 대해 알아봤다. 끗!

 

 

💫 참고

https://www.swift.org/blog/swift-openapi-generator-1.0/

 

Swift OpenAPI Generator 1.0 Released

We’re happy to announce the stable 1.0 release of Swift OpenAPI Generator! OpenAPI is an open standard for describing the behavior of HTTP services with a rich ecosystem of tooling. One thing OpenAPI is particularly known for is tooling to generate inter

www.swift.org

https://swiftpackageindex.com/apple/swift-openapi-generator/1.2.1/tutorials/swift-openapi-generator

 

swift-openapi-generator Documentation – Swift Package Index

 

swiftpackageindex.com

https://medium.com/@jpmtech/using-apples-openapi-generator-to-make-and-mock-network-calls-in-swiftui-0644fd3993b8