Обычно процесс разработки 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);
  },
);

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

Комментарии (0)