Обычно процесс разработки 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); }, );
Может показаться, что это экономия на спичках, но именно из таких мелочей строится чистая архитектура.
