Две недели назад ChiselStrike объявил о новом генераторе клиентского SDK для TypeScript, который обрабатывает детали вызова автоматически сгенерированного REST API для вашей модели данных. Вы можете использовать этот SDK в своих веб-приложениях и приложениях Node.js, чтобы упростить работу с данными объектов. Самое приятное то, что он сохраняет типы всех данных, поступающих по сети, поэтому будьте более уверены, что ваш код будет делать то, что вы ожидаете.

Выполнить запрос для всех экземпляров сущности безопасным для типов способом так же просто, как написать это:

const client = createChiselClient({ serverUrl })
const posts: BlogPost[] = await chiselClient.posts.getAll()

Достаточно выполнить одну команду (chisel generate) для создания исходного кода клиента, и вы готовы к работе. Однако за этой простотой скрывается множество сложностей, которые анализируют всю информацию о ваших объектах и ​​маршрутах, предоставляют правильные типы для вашего клиентского приложения, выдают правильные запросы и десериализуют данные ответов из внутреннего API.

В этой серии сообщений я расскажу вам о процессе реализации chisel generate и поделюсь некоторыми интересными аспектами этого начинания, которые я обнаружил в процессе. И даже если вы не энтузиаст генерации кода, я надеюсь, что вы вынесете из всего этого одно: генерация кода не должна быть пугающей!

Что мы генерируем?

Чтобы проиллюстрировать цель, давайте рассмотрим проект ChiselStrike с одним (сильно упрощенным) объектом:

// models/blog_post.ts
export class BlogPost extends ChiselEntity {
    text: string;
    tags: string[];
    publishedAt: Date;
    authorName: string;
    karma: number;
}

И карта маршрутизации, показывающая конечную точку CRUD для BlogPost:

// routes/index.ts
export default new RouteMap()
    .prefix("/blog/posts", BlogPost.crud());

Выше обратите внимание на использование статического метода crud(), который возвращает RouteMap, содержащую все конечные точки CRUD для объекта BlogPost, и делает этот маршрут доступным по пути /blog/posts. Как только этот код запустится в демоне ChiselStrike (chiseld), вы можете немедленно начать вызывать эти конечные точки с помощью любого HTTP-клиента. Но вместо этого большинство клиентских приложений предпочли бы использовать типобезопасный клиентский API.

При разработке нового API часто бывает полезно начать с желаемого использования, а потом побеспокоиться о реализации. Вот как это выглядит, учитывая вышеуказанный объект и маршрут.

Во-первых, фабричная функция для создания клиентского объекта с некоторой конфигурацией (как минимум, конечной точкой URL):

const client = createChiselClient({ serverUrl });

Далее, способ создать новую запись в блоге, используя этот клиентский объект, выполнив запрос POST:

const post: BlogPost = await client.blog.posts.post({
    text: "To like or not to like Trains, that is the question!",
    tags: ["lifeStyle"],
    publishedAt: new Date(),
    authorName: "I Like Trains",
    karma: 0,
});

Обратите внимание, как сегменты URL-пути сопоставляются со свойствами клиента. Путь /blog/posts становится вложенным свойством .blog.posts. Это хорошее место для начала, но мы также должны учитывать тот факт, что пути CRUD используют заполнители для захвата переменных. Например, чтобы ИСПРАВИТЬ конкретный пост в блоге, вы должны отправить HTTP-запрос ИСПРАВЛЕНИЕ для /blog/posts/:id, где :id указывает, куда в пути идет уникальный идентификатор сообщения. Эта функциональность отображается в API как метод id() в цепочке вызовов. Вот как мы хотим, чтобы это выглядело для этого PATCH в экземпляре BlogPost:

const updatedPost: BlogPost = await client.blog.posts.id(“[YOUR-ID]”).patch({
    tags: ["lifeStyle", "philosophy"],
});

Генерация типов сущностей (в Rust)

В приведенном выше коде вы можете видеть, что и post(), и patch() принимают объект BlogPost в качестве аргумента, а также возвращают BlogPost — это безопасность типов в этом API! Что нам нужно сделать, так это сгенерировать этот тип для использования клиентом (клиентское приложение не может совместно использовать код сущности с серверной частью — мы не можем просто повторно использовать исходный класс сущности BlogPost, как вы могли надеяться).

Что мы собираемся сделать, чтобы получить этот тип BlogPost для клиента, так это запросить chiseld данные модели, преобразовать их в похожий тип TypeScript и сохранить их в специальном файле с именем models.ts.

Сгенерированный тип сущности будет немного отличаться от исходной сущности, являющейся подклассом ChiselEntity, так как нам не нужно ничего сложного, как класс для клиента. Нам также необходимо добавить явное поле id, так как все ChiselEntities наследуют его. Вот как должен выглядеть окончательный тип для клиента:

// models.ts
export type BlogPost = {
    id: string;
    text: string;
    tags: string[];
    publishedAt: Date;
    authorName: string;
    karma: number;
};

Чтобы сгенерировать это, мы можем использовать следующий код (написанный на Rust). VersionDefinition содержит всю информацию об объектах и ​​маршрутах, полученную от chiseld:

fn generate_models(version_def: &VersionDefinition) -> Result<String> {
    let mut output = String::new();
    for def in &version_def.type_defs {
         writeln!(output, "export type {} = {{", def.name)?;
         for field in &def.field_defs {
             let field_type = field.field_type()?;
             writeln!(
                 output,
                 " {}{}: {};",
                 field.name,
                 if field.is_optional { "?" } else { "" },
                 type_enum_to_code(field_type)?
             )?;
         }
         writeln!(output, "}}")?;
    }
    Ok(output)
}

fn type_enum_to_code(type_enum: &TypeEnum) -> Result<String> {
    let ty_str = match &type_enum {
         TypeEnum::ArrayBuffer(_) => "ArrayBuffer".to_owned(),
         TypeEnum::Bool(_) => "boolean".to_owned(),
         TypeEnum::JsDate(_) => "Date".to_owned(),
         TypeEnum::Number(_) => "number".to_owned(),
         TypeEnum::String(_) | TypeEnum::EntityId(_) => "string".to_owned(),
         TypeEnum::Array(element_type) => {
             format!("{}[]", type_enum_to_code(element_type)?)
         }
         TypeEnum::Entity(entity_name) => entity_name.to_owned(),
    };
    Ok(ty_str)
}

Теперь я уверен, что вы пришли сюда в поисках TypeScript, но я только что дал вам кучу Rust, в основном вне контекста! Если вы не получите всего этого прямо сейчас, это нормально. Причина, по которой мы используем здесь Rust, заключается в том, что chiseld реализован в Rust, и мы обращаемся к его внутренностям, чтобы получить объект VersionDefinition, содержащий все необходимые нам данные.

Если вы покопаетесь в этом, вы увидите, что код выдает оператор export type с заполнителем для имени типа, а затем итеративно выдает отдельные свойства (поля) с их соответствующим типом.

Таким образом, мы переходим от исходного определения класса сущностей к сгенерированному типу для клиента. Пока довольно легко. По общему признанию, мы сэкономили много работы, извлекая уже проанализированные типы из бэкэнда ChiselStrike, но в будущем мы надеемся отказаться от этой зависимости.

Генерация клиентского объекта

Теперь, когда у нас есть типы, пришло время сосредоточиться на самом клиентском объекте. Чтобы заставить его работать, как показано в наших предыдущих примерах, мы можем использовать типы объектов TypeScript и вкладывать их для создания необходимой структуры. Это просто, это типизировано, и его будет легко сгенерировать по сравнению с кучей классов. Вот как выглядит клиентский объект:

const client = {
  blog: {
    posts: {
      post: (blogPost: Omit<BlogPost, "id">) => Promise<BlogPost> {...},
      // put, get, delete functions missing here
      id: (id: string) => {
        return {
          patch: (blogPost: Partial<BlogPost>) => Promise<BlogPost> {...}, …
          // get, delete missing here
        }
      }
    }
  }
}

Здесь определенно намного больше кода! Мы еще даже не реализовали ни одну из этих функций, и это еще не все методы CRUD. Нам по-прежнему не хватает GET, DELETE и PUT как для экземпляра объекта (с id), так и для варианта группы объектов (без идентификатора). Вероятно, мы также захотим иметь несколько удобных методов для массового поиска, например getAll().

Даже если мы планируем генерировать код, он нуждается в большей структуре. Хорошо, начнем с замены post: (user: Omit<User, “id”>) => Promise<User> {…} на что-то более полное.

Создание функции обработчика POST (в TypeScript)

Как насчет того, чтобы вместо создания функции в Rust написать функцию TypeScript, которая сгенерирует для нас обработчик? Это очень легко сделать с помощью стрелочных функций:

function makePostOne<Entity extends Record<string, unknown>>(
    url: URL,
    entityType: reflect.Entity,
): (entity: OmitRecursively<Entity, "id">) => Promise<Entity> {
      return async (entity: OmitRecursively<Entity, "id">) => {
           const entityJson = entityToJson(entityType, entity);
           const resp = await sendJson(url, "POST", entityJson);
           await throwOnError(resp);
           return entityFromJson<Entity>(entityType, await resp.json());
      };
  }

Если вы думаете: «Что, черт возьми, делает этот код?», не волнуйтесь, я объясню.

Функция makePostOne() принимает параметры url и entityType. url сообщает нам, куда будет отправлен запрос, а параметр отражения entityType используется для преобразования нашего объекта в JSON и из него. Подробнее об этом позже.

Функция makePostOne() возвращает асинхронную стрелочную функцию с сигнатурой (entity: OmitRecursively<Entity, “id”>) => Promise<Entity>. Параметр объекта должен быть OmitRecursively<Entity, “id”>, потому что, когда мы отправляем (создаем) новый объект, у нас еще нет идентификатора. База данных предоставит это для нас. Поскольку все наши сгенерированные типы сущностей содержат поле id, нам нужно опустить его и сделать это рекурсивным образом, поскольку сущности могут быть вложенными. (Чтобы узнать больше о реализации OmitRecursively, читайте здесь.) Тип возвращаемого значения не нуждается в таком ограничении, потому что функция возвращает только что созданную сущность, включая поле id.

Теперь к телу функции. Серверная часть ChiselStrike принимает объекты в формате JSON, поэтому сначала мы делаем это преобразование, используя entityToJson.

const entityJson = entityToJson(entityType, entity);

Это одна из тех забавных маленьких деталей, которые выглядят невинно, но, черт возьми, они сложны. Причина, по которой мы не можем просто сделать JSON.stringify, заключается в том, что мы поддерживаем такие типы, как Date и ArrayBuffer, которые несовместимы напрямую с JSON. Чтобы выполнить преобразование, нам нужен объект отражения entityType, описывающий тип. Функция также выполнит некоторую базовую проверку работоспособности, чтобы убедиться, что пользователь не обошел систему типов и не ввел контрабандой некоторые несоответствующие типы значений.

Этот процесс дает нам JSON-совместимый объект, который мы просто отправляем по сети, используя sendJson.

const resp = await sendJson(url, "POST", entityJson);

Затем проверяем ответ на наличие ошибок:

await throwOnError(resp);

Весь этот код до сих пор просто обрабатывает HTTP-запрос, поэтому теперь нам нужно сделать обратное для обработки HTTP-ответа, используя entityFromJson().

return entityFromJson<Entity>(entityType, await resp.json());

Используя этот подход для makePostOne(), мы можем добавить аналогичные функции-обработчики для PUT, PATCH и DELETE. Предполагая, что они уже существуют, давайте используем их для создания полной клиентской фабрики:

function createClient(serverUrl: string) {
  const url = (url: string) => {
    return urlJoin(serverUrl, url);
  };
  return  {
    blog: {
     posts: {
        post: makePostOne<BlogPost>(url(`/blog/posts`), reflection.BlogPost),
        delete: makeDeleteMany<BlogPost>(url(`/blog/posts`)),
        id: (id: string) => {
          return {
            delete: makeDeleteOne(url(`/blog/posts/${id}`)),
            patch: makePatchOne<BlogPost>(
              url(`/blog/posts/${id}`),
              reflection.BlogPost,
            ),
            put: makePutOne<BlogPost>(
              url(`/blog/posts/${id}`),
              reflection.BlogPost,
            ),
          };
        },
      },
    },
  };
}

Потрясающий! Это сэкономит нам много генерации, поскольку мы можем поместить функции makePostOne() и т. д. в библиотечный файл client_lib.ts, который является обычным файлом TypeScript, который можно линтинговать, анализировать или делать что угодно. Если вам интересно, вы можете посмотреть полный исходный код Rust для создания этих функций.

Что дальше

Возможно, вы заметили, что нашему клиенту по-прежнему не хватает возможности «получать» вещи. Конечные точки ChiselStrike CRUD GET предоставляют богатый набор параметров фильтрации и дополнительных параметров. Поддерживать все это безопасным способом — нетривиальная задача, и я расскажу об этом в следующем посте. После этого мы обсудим код функций entityToJson() и entityFromJson() и связанные с ними механизмы отражения.

Чтобы получать уведомления об этих будущих сообщениях, рассмотрите возможность подписаться на нас здесь, в Medium, Twitter или Discord. Надеюсь увидеть тебя там!