Главная / Статьи

Рассмотрение форм Prisma-Guard: права собственности, проекция и контракты записи

Научитесь рассматривать формы Prisma Guard как контракты API, задаваясь вопросами о том, кто владеет каждым значением, какие данные могут быть включены в ответ и какие операции записи могут выполняться через генерированные конечные точки.

Издатель: REACTAPP.TOP12 мин чтения2744 слов

Источники: Define Every Prisma Endpoint’s Inputs and Outputs in One Place

Язык аргументов Prisma обладает высокой выразительностью, и именно это необходимо в надежном серверном коде. Однако как только та же структура аргументов начинает использоваться через HTTP, каждая возможность этого языка превращается в решение, связанное с архитектурой продукта: что может написать вызывающий код, какие фильтры он может применить, что обязан зафиксировать сервер, какие связи могут быть возвращены и сколько дочерних записей может содержать один ответ. Функции prisma-guard позволяют записывать эти правила непосредственно рядом с операциями Prisma. В этом руководстве показано, как рассматривать такие правила как компактный контракт для управления, фильтрации, проектирования данных и их изменения, с использованием трех вопросов, которые можно задать при рассмотрении любого pull-реквеста, добавляющего генерируемый эндпоинт.

Почему shape — это нечто большее, чем просто валидация

Форма действительно проверяет входные данные, но её главное преимущество заключается в том, что она объединяет решения, связанные с границами, в одном месте. Генерируемая операция может опираться на эту форму, вместо того чтобы каждый обработчик вручную пересоздавал схемы данных и списки допустимых полей. Это устраняет повторяющийся код, а также снижает риск того, что два обработчика будут постепенно отклоняться в том, что они разрешают.

Ценой такого удобства является точность. Форма считается надежным контрактом только в том случае, если она соответствует тому, что на самом деле реализуется библиотекой, включая честность относительно того, где прекращает действовать область видимости верхнего уровня. Приведенное ниже поведение отражает реализацию в том виде, в котором она тестируется её поддерживающими разработчиками на момент написания, а не устоявшуюся общественную практику; поэтому уточняйте детали в документации к вашей версии. Если защищенный запрос уже не работает и вы хотите узнать причину, начните с нашей модели диагностики сбоев prisma-guard, основанной на фазах.

Первый вопрос: кому принадлежит каждое значение?

Каждый «лист» формы передает значение либо клиенту, либо серверу. Словарь возможных значений невелик; в примере показаны все четыре формата рядом друг с другом.

const values = {
  clientChooses: true,
  serverChooses: 'published',
  serverChoosesTrue: force(true),
  clientChoosesWithinRule: (base) => base.min(1).max(120),
}

Внимательно читайте описания:

  • Чистое значение true не является булевым значением. Это сигнал, означающий «решение принимает клиент».
  • Литерал, такой как 'published', принадлежит серверу.
  • force(value) также принадлежит серверу. Он необходим, когда значение, которое нужно задать, является литералом true, поскольку чистое значение true будет интерпретировано как сигнал клиента.
  • Функция уточнения оставляет решение за клиентом, но заменяет генерируемую цепочку Zod для поля на правило, указанное внутри кода.

Местоположение влияет на значение функции force

Сами слова просты; людей удивляет их расположение. В формате where принудительный скаляр помещается внутрь объекта оператора. Фильтр событий ниже фиксирует статус публикации, но при этом позволяет клиенту искать по заголовкам:

const eventShape = {
  where: {
    isPublished: { equals: force(true) },
    title: { contains: true },
  },
}

Когда весь скалярный предикат у корня элемента where принудительно задан, библиотека терпимо относится к вводу данных клиента в этом поле. Независимо от того, повторяет ли пользователь фиксированное значение, отправляет другое или добавляет какой-либо другой оператор, предикат сервера имеет приоритет, и запрос не отклоняется лишь из-за упоминания этого поля. Это не то же самое, что значение по умолчанию. Значение по умолчанию отступает на второй план, когда клиент предоставляет собственные данные; принудительное значение же никогда не отступает.

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

Это важно для кода клиента. Клиенту может потребоваться полностью убрать поле для записи, принадлежащее серверу. Даже «исправление» его значениями сервера не поможет, поскольку проверка касается того, кому принадлежит ключ, а не того, равны ли значения.

По умолчанию, обработка исключений и доработки также влияют на принадлежность ключа

Параметры по умолчанию и механизмы обработки ошибок заслуживают одинакового внимания. На поле, контролируемом клиентом, параметр по умолчанию сохраняет своё нормальное поведение при отсутствии значения, а механизм обработки ошибок может заменить некорректный ввод на альтернативное значение вместо того, чтобы отклонить запрос. Операции создания добавляют дополнительную сложность: директива по умолчанию или обработки ошибок, сгенерированная генератором, может даже заполнить обязательное поле, о котором в спецификации формы не упоминается, причём созданное ею значение вставляется так, будто сервер его задал. Следовательно, место размещения параметра по умолчанию определяет, кто является владельцем значения; поэтому полагайтесь на замену только тогда, когда это требуется условиями API-контракта.

Усовершенствования скрывают связанную ловушку. Они не расширяют генерируемую цепочку в своем узле; они заменяют её. Поэтому ваша функция должна перечислить все ограничения, от которых зависит конечная точка, а не только дополнительные минимальные или максимальные значения, которые вы хотели добавить. Это особенно важно, когда тип Prisma менее строг, чем правило произведения, например, когда речь идёт о столбце с текстом, который никогда не должен быть пустым.

Принудительные обновления и принудительные дизъюнкции

Данные обновления, закреплённые для использования, записываются при каждом соответствующем вызове. Предположим, что форма обновления требует значение status: 'active': любое обновление через неё, даже такое, которое изменяет только название, также сбрасывает статус. Если операция должна оставить поле нетронутым, исключите это поле из формы обновления.

Структура типа Boolean добавляет ещё одно правило. Принудительное условие, размещённое под оператором OR, переносится в категорию обязательных условий на уровне корня. В результате форма не может отразить настоящее правило вида «либо… либо», принадлежащее серверу, например записи, которые относятся к этому аккаунту или были разделены с этой группой. Для таких правил требуется запрос, написанный на сервере, отдельный столбец для определения диапазона, который фиксирует правила, или механизм применения правил непосредственно в базе данных.

Поэтому первый вопрос для анализа более точен, чем «прошло ли это проверку?». Необходимо спросить, кто определяет каждое значение, где размещаются все принудительные условия и какая структура аргументов в конечном итоге попадает в Prisma.

Второй вопрос: что может раскрыть ответ?

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

Для защищённых операций чтения операторы select или include, объявленные для структуры данных, одновременно служат списком разрешённых элементов и запасным вариантом. Клиенты могут запросить узкую структуру данных, но не более того; если они не указывают проекцию, используется заранее объявленная.

Структура данных билета, показанная ниже, позволяет клиенту получить билет по его идентификатору, возвращает фиксированный набор корневых полей и предоставляет вложенный список заметок клиента с собственным фильтром, полями и размером страницы.

const ticketShape = {
  where: {
    id: { equals: true },
  },
  select: {
    id: true,
    subject: true,
    description: true,
    customerNotes: {
      where: {
        accountId: { equals: force(accountId) },
      },
      select: {
        id: true,
        body: true,
      },
      take: { max: 20, default: 10 },
    },
  },
}

Эта структура выполняет четыре отдельных задачи:

  • Корневые поля, которые не указаны в списке, остаются недоступными.
  • Возвращаются только те поля заметок, которые указаны в списке.
  • Заметки всегда ограничиваются аккаунтом звонящего.
  • В одном ответе может находиться максимум 20 заметок, а если клиент не указал иное — 10.
  • Вложенные связи находятся вне автоматического диапазона

    Фильтр заметок реализован намеренно таким образом. Автоматическое определение диапазона применяется только к поддерживаемым операциям в корне запроса; он не распространяется на связи, полученные с помощью select или include, и не добавляет там дополнительные условия. Можно забыть о вложенных условиях where — даже при правильном определении диапазона запрос может вернуть заметки, принадлежащие другому аккаунту.

    В отношениях типа «многие к многим» допускается использование вложенного условия where, собственной проекции и ограничения по строкам. В отношениях типа «один к чувствительному» нельзя использовать вложенное условие where в Prisma, поэтому требуется другая стратегия: исключить это отношение, выделить из него только безопасные скалярные поля, загрузить его с помощью отдельного защищенного запроса на уровне корня или защитить его с помощью политики базы данных.

    Выбор стиля проекции

    Форму проекции следует выбирать с учетом того, как она будет работать по мере расширения схемы:

    • При использовании select столбец, добавленный позже в модель, остается скрытым до тех пор, пока кто-то не проверит его и не включит в результат, поэтому это самый безопасный вариант для публичных конечных точек.
    • При использовании include вы получаете отношения, но корневая модель все равно возвращает все свои скалярные столбцы, поэтому чувствительные данные корневой модели остаются видимыми.
  • С использованием omit вы сохраняете список запрещённых элементов, и наличие такого списка зависит от вашего генератора и версии Prisma.
  • Это альтернативы, а не уровни структуры. Никогда не используйте select, include и omit одновременно на одном уровне дерева.

    Подсчёты, вложенные значения по умолчанию и ответы на мутации

    Подсчёт элементов связи представляет собой отдельный узел проекции, а не сводку, связанную с загруженной связью. Если связь типа «один к многим», возвращаемая после фильтрации по учётной записи, то её _count должен содержать тот же фильтр, поскольку клиенты будут считать, что оба числа относятся к одним и тем же строкам. Тестируйте условия генерации связи и условия подсчёта отдельно друг от друга.

    По умолчанию вложенные проекции определяются на основе объявленной структуры. Если клиент запрашивает отношение, описанное с использованием булевского сокращения, это отношение может сохранить свой фиксированный фильтр и ограничения по странице. Однако разрешения, предоставленные клиенту, не заполняют отсутствующие варианты: разрешение сортировки по полю не указывает направление, которое клиент пропустил. В тестах необходимо рассмотреть три случая — отсутствие корневой проекции, явную вложенную проекцию и использование сокращений для отношений, вместо того чтобы предполагать, что один случай доказывает остальные.

    Мутации преднамеренно асимметричны. Форма мутации может проверять проекцию, запрошенную клиентом, но если параметр enforceProjection не включен, клиент, который ничего не запрашивает, не получает проекцию в указанной форме, и действует обычное поведение Prisma. Включайте этот параметр каждый раз, когда ответы на мутации должны ограничиваться объявленными полями.

    В заключение убедитесь, что каждый тест выполняет ту фазу проверки, которую он должен протестировать. Вызов guard.query(...).parse() — хороший способ проверки фильтров чтения и границ, но параметры проекции данных для чтения отображаются только после выполнения защищенного делегата, а проекция данных для изменений также требует использования соответствующего пути изменения.

    Поэтому второй вопрос звучит не «есть ли операция select?», а «какие корневые поля, связанные поля, связанные строки и их количество могут быть получены каждым вариантом вызова?»

    Третий вопрос: какие операции записи могут выполняться?

    Генерируемые концовки для операций записи принимают аргументы в собственном формате Prisma. Структура данных определяет, какие значения предоставляет клиент, какие — сервер, а также возможность выполнения вложенных операций.

    Структуры для создания данных должны учитывать все обязательные поля

    Структура для создания данных отклоняется, если у какого-либо обязательного поля нет источника данных. Допустимыми источниками являются:

    • клиент, когда поле отмечено как контролируемое клиентом
    • сервер, через литеральное значение или метод force
    • значение по умолчанию, определенное в схеме Prisma
    • поддерживаемое значение по умолчанию из Zod или механизм обработки ошибок
    • внедрение внешнего ключа арендатора в контекст операции
    • допустимая запись связи, заполняющая этот ключ

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

    const createSubscriptionShape = {
      data: {
        externalKey: (base) => base.min(1).max(100),
        planId: true,
        status: 'trialing',
        seatCount: (base) => base.int().min(1).max(500),
        version: 0,
      },
      select: {
        id: true,
        externalKey: true,
        status: true,
        seatCount: true,
      },
    }
    

    Обратите внимание на отсутствующий элемент: внешний ключ арендатора. Его никогда не должен устанавливать клиент при создании записи. Корень контекста операции берется из надежного запроса, а расширение само добавляет соответствующий внешний ключ для записей корневого уровня, которые оно поддерживает.

    Массовые операции и создание группой

    Пакетные мутации должны пройти две проверки. Во-первых, функции updateMany, возвращаемые ею варианты и deleteMany требуют наличия структуры для указания условий where. Во-вторых, входящий запрос должен содержать реальное условие. Отсутствие тела запроса, пустой объект оператора или логическая ветвь, соответствующая всем элементам, не являются допустимыми, что предотвращает случайные изменения во всей таблице.

    Функция создания пакетных операций требует массива и не принимает в качестве аргумента отдельный объект. То, будут ли возвращаться результаты пакетных операций и такие опции, как пропуск дубликатов, по-прежнему зависит от поставщика базы данных.

    Упсерт предусматривает три формата

    Структура упсерта на самом деле состоит из трех частей: уникальный селектор, данные для создания и данные для обновления.

    const upsertSubscriptionShape = {
      where: {
        externalKey: true,
      },
      create: {
        externalKey: true,
        planId: true,
        status: 'trialing',
        seatCount: true,
        version: 0,
      },
      update: {
        planId: true,
        seatCount: true,
      },
    }
    

    В теле запроса на создание необходимо указать все обязательные поля, в то время как при обновлении можно опустить что угодно. Параметр create задаёт значение только при вставке строки, тогда как аналогичный параметр update перезаписывает его каждый раз, когда находится существующая строка с соответствующими данными. В примере поля status и version задаются при создании, но намеренно не указываются при обновлении, поэтому существующие подписки сохраняют свои текущие значения.

    Уникальные операции используют простой формат уникального выборщика от Prisma. Объекты операторов предназначены для обычной фильтрации и не могут использоваться в структуре уникального условия where на уровне корня.

    Контракты записи и возврата связей

    Механизм связей требует отдельного решения о правах собственности. Форма может разрешать метод connect, ограничивать клавиши-селекторы, которые может использовать клиент, и отклонять любые вложенные операции, не указанные в ней. Однако ничто из этого не распространяет диапазон прав арендатора на селектор. Когда важно определить, к какой записи будет установлена связь, код вашего приложения или база данных должны проверить, разрешено ли вызывающему коду её использовать.

    Контракты возврата также различаются в зависимости от метода. Методы пакетной обработки, предназначенные только для подсчёта количества — createMany, updateMany и deleteMany — возвращают информацию о количестве и отклоняют проекции. Их аналоги, возвращающие данные, могут принимать проекции, если это разрешено как Prisma, так и вашим поставщиком. Если настройка генератора переводит маршрут на метод возврата данных, необходимо рассмотреть новый ответ как изменение API, а не игнорировать его как деталь реализации.

    Третий вопрос — не «включен ли этот метод?», а что именно предоставляет данный метод: поля данных, формы выбора, условия массовой обработки, операции с отношениями и поля ответа.

    Многоуровневая реализация правил

    Формы работают наилучшим образом, когда их ограничения документируются вместе с другими механизмами. У каждого уровня есть своя задача:

    • Аутентифицированный запрос предоставляет надежный контекст.
    • Сопоставления диапазонов добавляют условия арендатора к поддерживаемым операциям корневого уровня.
    • Варианты вызова определяют, какая из объявленных форм применима к конкретной аудитории.
    • Хуки выполняют ограниченные проверки приложения, которые могут привести к отклонению запроса.
    • Ручные обработчики отвечают за запросы, которые формы не могут описать.
    • Политика базы данных защищает неизменяемые свойства, которые должны сохраняться при любом пути доступа.

    Ни один из этих инструментов не должен претендовать на выполнение работы другого. Если вы решаете, какие маршруты генерировать, а какие писать вручную, наша статья о балансировке генерации Prisma CRUD и целенаправленного управления маршрутами рассматривает этот вопрос.

    Чек-лист для проверки и тестирования

    Когда pull request добавляет генерируемый конечный пункт, проверьте его с помощью трех вопросов:

    1. Кто определяет каждое значение, которое принимает конечный пункт?
    2. Какие поля и связи могут содержаться в ответе?
    3. Какие операции изменения и формы выбора могут использоваться вызывающим кодом?

    Затем настройте каждый тест так, чтобы он проверял конкретное утверждение, которое должен подтвердить. Проанализируйте аргументы входных данных для проверки поведения фильтров. Запустите защищённые делегаты для проверки операций проекции и записи. Изучите путь расширения, чтобы убедиться в правильности инъекции контекста. Используйте фикстуры многоклиентской базы данных, когда речь идёт о том, какие строки возвращаются, а для HTTP-тестов — когда речь идёт о протоколе передачи или генерируемых маршрутах.

    Придайте отрицательным тестам такую же важность. Для каждой группы пользователей попробуйте прочитать корневое поле, которое не указано в списке, прочитать чувствительное вложенное поле, превысить ограничение связи и выполнить операцию записи, запрещённую форматом. Положительные тесты подтверждают, что ожидаемый запрос успешен; отрицательные тесты демонстрируют, что критические уязвимости остаются закрытыми после изменений в модели или генераторе.

    Заключение

    Форма получает название «контракт» только тогда, когда ясно, что она позволяет делать и чего не может обеспечить. Раздельный анализ прав собственности, проекций и операций записи позволяет заранее выявить типичные пробелы: слишком мягкие правила на уровне корня, неразмеченные вложенные связи, подсчёты, игнорирующие фильтры связей, и операции подключения, обходящие ограничения тенантов. Как только эти пробелы связываются с соответствующими хуками, обработчиками или правилами базы данных, генерация кода может устранить повторяющийся код границ без превращения языка запросов Prisma в публичный API, который никто не планировал публиковать. Полный синтаксис, точные сообщения об ошибках, поведение в зависимости от версии и примеры тестов с возможностью воспроизведения смотрите в руководстве prisma-generator-express.