Контракт из кода, клиент из контракта: избавляемся от тройного дублирования в API

от автора

Обычно процесс разработки API выглядит так: мы пишем контроллер. Затем каким-то образом его документируем. После чего фронтер, опираясь на такую документацию, пишет клиент.

Мы делаем одну и ту же работу трижды.

В прошлой статье я рассказывал, как избавиться от первого дублирования. С помощью бандла sunrise-studio/symfony-openapi можно генерировать OpenAPI-документ из кода, минуя процесс документирования.

Но это решает проблему только наполовину. Если OpenAPI-документ вытекает из кода, то клиент должен вытекать из OpenAPI-документа. Иначе написание клиента – и есть то самое дублирование.

В этой статье я расскажу как замкнуть цепочку:
Controller → OpenAPI → Client → Feature
Где каждый последующий шаг вытекает из предыдущего, а не дублирует его. 


Оглянемся назад

🎶 Carry On Wayward Son 🎶

Напомню контроллер из прошлой статьи:

#[Route('/v1/completions', name: 'createCompletion', methods: ['POST'])]final readonly class CreateCompletionController{    public function __invoke(        #[MapRequestPayload] CreateCompletionRequest $request,    ): CompletionView {        // ...    }}

Из которого бандл генерирует OpenAPI-документ, который должен рассматриваться как контракт, а не как документация.

Сгенерированный контракт
{  "openapi": "3.1.1",  "info": {    "title": "app",    "version": "1.0.0"  },  "paths": {    "/v1/completions": {      "post": {        "responses": {          "default": {            "description": "The operation was unsuccessful.",            "content": {              "application/json": {                "schema": {                  "$ref": "#/components/schemas/ErrorResponseView"                }              }            }          },          "201": {            "description": "The operation was successful.",            "content": {              "application/json": {                "schema": {                  "$ref": "#/components/schemas/CompletionView"                }              }            }          }        },        "requestBody": {          "content": {            "application/json": {              "schema": {                "$ref": "#/components/schemas/CreateCompletionRequest"              }            }          },          "required": true        },        "operationId": "createCompletion",        "tags": [          "Completions"        ],        "summary": "Creates completion",        "description": "Creates text completion for the given prompt."      }    }  },  "components": {    "schemas": {      "ErrorView": {        "type": "object",        "additionalProperties": false,        "properties": {          "key": {            "type": "string"          },          "message": {            "type": "string"          }        },        "required": [          "key",          "message"        ]      },      "ErrorResponseView": {        "type": "object",        "additionalProperties": false,        "properties": {          "message": {            "type": "string"          },          "errors": {            "type": "array",            "items": {              "$ref": "#/components/schemas/ErrorView"            }          }        },        "required": [          "message"        ]      },      "CompletionView": {        "type": "object",        "additionalProperties": false,        "properties": {          "text": {            "type": "string"          }        },        "required": [          "text"        ]      },      "CreateCompletionRequest": {        "type": "object",        "additionalProperties": false,        "properties": {          "prompt": {            "type": "string"          }        },        "required": [          "prompt"        ]      }    }  }}

Клиент из контракта

Как контракт был выведен из кода, так и клиент должен быть выведен из контракта. Для этого используем Orval – пакет, который генерирует типобезопасные клиенты из OpenAPI-документа.

orval --input http://localhost:8000/docs/openapi.json --output ./src/api/client.ts
Сгенерированный клиент
/** * Generated by orval v7.10.0 🍺 * Do not edit manually. * app * OpenAPI spec version: 1.0.0 */import axios from 'axios';import type {  AxiosRequestConfig,  AxiosResponse} from 'axios';export interface ErrorView {  key: string;  message: string;}export interface ErrorResponseView {  message: string;  errors?: ErrorView[];}export interface CompletionView {  text: string;}export interface CreateCompletionRequest {  prompt: string;}/** * Creates text completion for the given prompt. * @summary Creates completion */export const createCompletion = <TData = AxiosResponse<CompletionView>>(    createCompletionRequest: CreateCompletionRequest, options?: AxiosRequestConfig ): Promise<TData> => {    return axios.post(      `/v1/completions`,      createCompletionRequest,options    );  }export type CreateCompletionResult = AxiosResponse<CompletionView>

Рядом с клиентом создаем и импортируем bootstrap.ts, чтобы была возможность настроить его.

import axios from 'axios';axios.defaults.baseURL = 'http://localhost:8000';

В итоге мы находимся в точке, когда руками написан только контроллер, в то время как все остальное – сгенерировано. Вместо написания бойлерплейта мы фокусируемся на фиче.

import React from 'react';import { Button, Text, TextInput, View } from 'react-native';import { Controller, useForm } from 'react-hook-form';import { createCompletion, CreateCompletionRequest } from '@/src/api/client';export default function CompletionForm() {  const {    control,    handleSubmit,    setError,    formState: {      isSubmitting,    },  } = useForm<CreateCompletionRequest>({    defaultValues: {      prompt: '',    },  });  const onSubmit = (data: CreateCompletionRequest) => createCompletion(data, {    setFormError: setError,  }).then(response => {    // some logic  }).catch(error => {    // error handling  });  return (    <View>      <Controller        name="prompt"        control={control}        render={({ field: { value, onChange }, fieldState: { error } }) => (          <View>            <TextInput value={value} onChangeText={onChange} placeholder="Prompt" />            {error && <Text>{error.message}</Text>}          </View>        )}      />      <Button title="Complete" disabled={isSubmitting} onPress={handleSubmit(onSubmit)} />    </View>  );}

Пример выше на базе привычного для меня стека (React Native и react-hook-form), который может быть любым и никак не связан с Orval.

Обработка ошибок

В прошлой статье была выстроена чистая архитектура обработки ошибок на бэке, на фронте она может быть еще тоньше. Форма выше не нуждается в доработках, достаточно просто изменить bootstrap.ts.

import axios from 'axios';import { ErrorResponseView, ErrorView } from './client';import { UseFormSetError } from 'react-hook-form';declare module 'axios' {  interface AxiosRequestConfig {    setFormError?: UseFormSetError<any>;  }}axios.defaults.baseURL = 'http://localhost:8000';axios.interceptors.response.use(  response => response,  error => {    if (axios.isAxiosError<ErrorResponseView>(error)) {      error.response?.data?.errors?.forEach((errorView: ErrorView) => {        error.config?.setFormError?.(errorView.key, {          message: errorView.message,        });      });    }    return Promise.reject(error);  },);

Может показаться, что это экономия на спичках, но именно из таких мелочей строится чистая архитектура.

ссылка на оригинал статьи https://habr.com/ru/articles/1055764/