Свой велосипед для JSON API

    Всем привет! На недавно прошедшем Superjob IT Meetup я рассказывал о том, как мы в Superjob разрабатываем свой API для проекта с миллионной аудиторией и кучей различных платформ.

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


    Вместо вступления


    История API в Superjob началась с сурового XML API. От него мы перешли к лаконичному JSON, а позже, устав от споров по поводу того, что же правильнее — {success: true} или {result: true}, внедрили JSON API. Со временем мы отказались от некоторых его фич, договорились о форматах данных и написали свою версию спеки, которая сохраняла обратную совместимость с оригиналом. Ровно на этой спеке работает последняя, третья версия нашего API, на которую мы постепенно переводим все наши сервисы.

    Для наших задач, когда большинство эндпойнтов в API принимают или отдают некие объекты, JSON API оказался почти идеальным решением. В основе этой спеки — сущности и их связи. Сущности типизированы, имеют фиксированный набор атрибутов и связей и по своей сути очень похожи на модели, с которыми мы привыкли работать в коде. Работа с сущностями осуществляется в соответствии с принципами REST — протокола поверх HTTP, как, например, в SOAP или JSON-RPC, нет. Формат запроса практически полностью повторяет формат ответа, что сильно облегчает жизнь и серверу, и клиенту. Например, типичный ответ JSON API выглядит так:

    {
        "data": {
            "type": "resume",
            "id": 100,
            "attributes": {
                "position": "Курьер"
            },
            "relationships": {
                "owner": {
                    "data": {
                        "type": "user",  
                        "id": 200
                    }
                }
            }
        },
        "included": [
            {
                "type": "user",
                "id": 200,
                "attributes": {
                    "name": "Василий Батарейкин"
                }
            }
        ]
    }

    Здесь мы видим сущность типа resume, со связью owner на сущность типа user. Если бы клиент захотел нам отправить такую сущность, точно такой же json он бы положил в тело запроса.

    Первые шаги


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

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

    Для маппинга модели в сущность нужно было описать два дополнительных класса: DTO для сущности и гидратор, который наполнял бы DTO данными из модели. Такой подход делал процесс маппинга достаточно гибким, однако на деле эта гибкость оказалась злом: наши гидраторы со временем стали обрастать копипастой, а необходимость для каждой модели заводить ещё 2 класса приводила к распуханию нашей кодовой базы.

    Транспортный слой также был далёк от идеала. Разработчик был вынужден постоянно думать о внутреннем устройстве JSON API: как и в случае с маппингом моделей, полный контроль над процессом приводил к необходимости таскать из экшена в экшен практически идентичный код.

    Мы стали думать о переходе на стороннее решение, работающее с JSON API. На сайте JSON API есть довольно внушительный список имплементаций спеки на самых разных языках как для сервера, так и для клиента. Проектов, реализующих серверную часть на PHP, на момент написания статьи там насчитывалось 18, из которых ни один нам не подошёл:

    • Во-первых, сторонние решения имели всё те же проблемы, что и наше собственное, — слишком много лишнего кода, мало автоматизации. В некоторых случаях к моделям предъявлялись определённые требования (например, имплементация интерфейса), а с нашим объемом кода это могло вылиться в серьёзный рефакторинг. Для работы запросов и ответов нам в любом случае пришлось бы писать адаптер, связывающий выбранное решение с Yii.

    • Во-вторых, подавляющее количество сторонних решений поддерживало маппинг один в один: у вас есть одна модель, вы можете превратить её в одну сущность. Это нормальный кейс, когда данные в моделях хранятся в том виде, в каком вы хотели бы отдать их клиенту, однако на деле это не всегда так. Например, у модели резюме есть атрибуты с контактами, но эти контакты клиент может получить только при определённых условиях. Было бы здорово вынести контакты в отдельную сущность, связанную с сущностью самого резюме, превратив таким образом одну модель в несколько сущностей, но в сторонних решениях такое можно сделать только через костыли.

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

    • Наконец, в-четвёртых, мы хотели упростить написание документации и тестов, но сторонние решения в большинстве своем не предоставляли никакой информации о том, какие атрибуты и связи есть у той или иной сущности.

    Необходимость вновь приступить к написанию своего решения становилась очевидной :)

    Разработка фреймворка


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

    • Прежде всего вместо написания DTO и гидраторов весь маппинг мы решили описывать в конфиге.
    • Этот конфиг незаметно для разработчика должен был компилироваться в PHP-код, который, в свою очередь, использовался бы для гидрации сущностей.
    • Вся работа с JSON API должна была вестись за сценой: предполагалось, что для типовых эндпойнтов вся работа будет сводиться к описанию бизнес-логики и получению моделей.
    • Наконец, как уже упоминалось выше, мы хотели интегрировать наше решение с DBAL, документацией и тестами.

    Ядро


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

    Попробуем описать конфиг для такого гидратора. Формат конфига — YAML, который легко пишется, легко читается и легко парсится (у себя мы использовали symfony/yaml).

    entities:
        TestEntity:
            classes:
                - TestModel
            attributes:
                id:
                    type: integer
                    accessor: '@getId'
                    mutator: '@setId'
                name:
                    type: string
                    accessor: name
                    mutator: name
            relations:
                relatedModel:
                    type: TestEntity2
                    accessor: relatedModel
                relatedModels:
                    type: TestEntity3[]
                    accessor: '@getRelatedModels'

    Здесь сущность TestEntity собирается из модели TestModel. У сущности два атрибута: id, который получается из геттера getId, и name — из свойства name. Так же у сущности есть две связи: одиночная relatedModel, которая состоит из сущности типа TestEntity2, и множественная relatedModels, которая состоит из сущностей TestEntity3.

    Скомпилированный по такому конфигу гидратор выглядит следующим образом:

    class TestEntityHydrator extends Hydrator
    {
        public static function getName(): string
        {
            return 'TestEntity';
        }
    
        protected function getClasses(): array
        {
            return [Method::DEFAULT_ALIAS => TestModel::class];
        }
    
        protected function buildAttributes(): array
        {
            return [
                'id' => (new CompiledAttribute('id', Type::INTEGER))
                    ->setAccessor(
                        new MethodCallable(
                            Method::DEFAULT_ALIAS, function (array $modelArray) {
                                return $modelArray[Method::DEFAULT_ALIAS]->getId();
                            }
                        )
                    )
                    ->setMutator(
                        new MethodCallable(
                            Method::DEFAULT_ALIAS,
                            function (array $modelArray, $value) {
                                $modelArray[Method::DEFAULT_ALIAS]->setId($value);
                            }
                        )
                    ),
                'name' => (new CompiledAttribute('name', Type::STRING))
                    ->setAccessor(
                        new MethodCallable(
                            Method::DEFAULT_ALIAS, function (array $modelArray) {
                                return $modelArray[Method::DEFAULT_ALIAS]->name;
                            }
                        )
                    )
                    ->setMutator(
                        new MethodCallable(
                            Method::DEFAULT_ALIAS,
                            function (array $modelArray, $value) {
                                $modelArray[Method::DEFAULT_ALIAS]->name = $value;
                            }
                        )
                    )
                    ->setRequired(false),
            ];
        }
    
        protected function buildRelations(): array
        {
            return [
                'relatedModel' => (new CompiledRelation('relatedModel', TestEntity2Hydrator::getName()))->setAccessor(
                    new MethodCallable(
                        Method::DEFAULT_ALIAS, function (array $modelArray) {
                            return $modelArray[Method::DEFAULT_ALIAS]->relatedModel;
                        }
                    )
                ),
                'relatedModels' => (new CompiledRelation('relatedModels', TestEntity3Hydrator::getName()))->setAccessor(
                    new MethodCallable(
                        Method::DEFAULT_ALIAS, function (array $modelArray) {
                            return $modelArray[Method::DEFAULT_ALIAS]->getRelatedModels();
                        }
                    )
                )->setMultiple(true),
            ];
        }
    }
    

    Весь этот монструозный код, по сути, лишь описывает те данные, которые есть в сущности. Согласитесь, писать такое от руки, да ещё и для каждой сущности, которая есть в проекте, было бы совсем не здорово.

    Для того, чтобы всё описанное выше заработало, нам потребовалось реализовать три сервиса: парсер конфига, валидатор и компилятор.

    Парсер занимался тем, что следил за изменениями конфига (в этом нам помог symfony/config) и в случае обнаружения таких изменений перечитывал все файлы конфига, объединял их и передавал валидатору.

    Валидатор проверял корректность конфига: сперва проверялось соответствие json schema, которую мы описали для нашего конфига (тут мы использовали justinrainbow/json-schema), а затем проверялись на существование все упомянутые классы, их свойства и методы.

    Наконец, компилятор брал отвалидированный конфиг и собирал из него PHP-код.

    Интеграция с DBAL


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

    Чтобы достичь этого, нам прежде всего потребовалось внести небольшие изменения в конфиг. Поскольку в общем случае имя связи в модели может отличаться от имени геттера или свойства, возвращающего эту связь (особенно справедливо это для Doctrine), то нам нужно было уметь рассказать Mapper’у, под каким именем знает ту или иную связь DBAL. Для этого в описание связи мы добавили параметр internalName. Позже этот же internalName появился и у атрибутов, чтобы Mapper мог самостоятельно выполнять выборки по полям.

    Помимо internalName, мы добавили в конфиг знание о том, к какому именно DBAL относится сущность: в параметре adapter указывалось название сервиса, который имплементировал интерфейс, позволяющий Mapper’у взаимодействовать с DBAL.

    Интерфейс имел следующий вид:

    interface IDbAdapter
    {
        /**
         * Statement по контексту.
         *
         * @param string $className
         * @param mixed  $context
         * @param array  $relationNames
         *
         * @return IDbStatement
         */
        public function statementByContext(string $className, $context, array $relationNames): IDbStatement;
    
        /**
         * Statement по значениям атрибутов.
         *
         * @param string $className
         * @param array  $attributes
         * @param array  $relationNames
         *
         * @return IDbStatement
         */
        public function statementByAttributes(string $className, array $attributes, array $relationNames): IDbStatement;
    
        /**
         * Инстанцировать модель указанного класса.
         *
         * @param string $className
         *
         * @return mixed
         */
        public function create(string $className);
    
        /**
         * Сохранить модель.
         *
         * @param mixed $model
         */
        public function save($model);
    
        /**
         * Выполнить привязку одной модели к другой.
         *
         * @param mixed  $parent
         * @param mixed  $child
         * @param string $relationName
         */
        public function link($parent, $child, string $relationName);
    
        /**
         * Отвязать одну модель от другой.
         *
         * @param mixed  $parent
         * @param mixed  $child
         * @param string $relationName
         */
        public function unlink($parent, $child, string $relationName);
    }
    


    Для того чтобы упростить взаимодействие с DBAL, мы ввели понятие контекста. Контекст — это некий объект, получив который, DBAL должен был понять, какой запрос он должен выполнить. В случае с ActiveRecord в качестве контекста используется CDbCriteria, для Doctrine — QueryBuilder.

    Для каждого DBAL мы написали свой адаптер, имплементирующий IDbAdapter. Не обошлось без сюрпризов: например, оказалось, что за всё время существования Yii1 не было написано ни одного расширения, которое поддерживало бы сохранение всех видов связей, —пришлось писать собственную обёртку.

    Документация и тесты


    У себя мы используем Behat для интеграционных тестов и Swagger для документирования. Оба инструмента нативно поддерживают JSON Schema, что позволило нам без особых проблем встроить в них поддержку Mapper’а.

    Тесты для Behat пишутся на языке Gherkin. Каждый тест представляет собой последовательность шагов, а каждый шаг — предложение на натуральном языке.

    Мы добавили шаги, которые интегрировали поддержку JSON API и Mapper в Behat:

    # Описываем сущность
    When I have entity "resume"
    And I have entity attributes:
      | name   	| value 	|
      | profession | Кладовщик |
      
    # Описываем связь
    And I have entity relationship "owner" with data:
      | name       	| value |
      | id         	| 100   |
     
    # Отправляем запрос и проверяем, что вернулась сущность resume
    Then I send entity via "POST" to "/resume/" and get entity "resume"


    В этом тесте мы создаем сущность резюме, заполняем её атрибуты и связи, отправляем запрос и валидируем ответ. При этом вся рутина автоматизирована: нам не нужно составлять тело запроса, поскольку этим занимаются наши хелперы для Behat, нам не нужно описывать JSON Schema ожидаемого ответа, так как его сгенерирует Mapper.

    С документацией ситуация несколько интереснее. Файлы JSON Schema для Swagger у нас изначально генерировались на лету из исходников на YAML: как уже упоминалось, YAML значительно проще в написании, чем тот же JSON, но Swagger понимает только JSON. Мы дополнили этот механизм так, чтобы в итоговую JSON Schema попадало не только содержимое YAML-файлов, но и описания сущностей из маппера. Так, например, мы научили Swagger понимать ссылки вида:

    $ref: '#mapper:resume'

    Или:

    $ref: '#mapper:resume.collection.response'

    И Swagger рендерил объект сущности resume или целиком объект ответа сервера с коллекцией сущностей resume соответственно. Благодаря таким ссылкам, как только менялся конфиг Mapper’а, автоматически обновлялась документация.

    Выводы


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

    Пришло время ответить на основной вопрос, который я озвучил в начале статьи, — чего же стоило нам сделать свой велосипед? И нужно ли вам делать свой?

    Интенсивная фаза разработки Mapper’а заняла у нас около трех месяцев. Мы до сих пор продолжаем добавлять в него новые фичи, но в значительно менее интенсивном режиме. В целом мы довольны результатом: поскольку Mapper проектировался с учётом особенностей нашего проекта, он справляется с возложенными на него задачами значительно лучше, чем любое стороннее решение.

    Стоит ли вам идти нашим путём? Если ваш проект ещё молод и кодовая база невелика, вполне возможно, что написание своего велосипеда для вас будет неоправданной тратой времени, и лучшим выбором будет интегрировать стороннее решение. Однако если ваш код писался на протяжении многих лет и вы не готовы проводить серьёзный рефакторинг, то определенно стоит задуматься о своем собственном решении. Несмотря на изначальные сложности при разработке, оно может существенно сэкономить вам время и силы в дальнейшем.
    SuperJob.ru 111,63
    Компания
    Поделиться публикацией
    Комментарии 40
    • +1
      Обьясните мне, люди, чем отличается этот ваш JSON API от всем известного REST API?
      • +2
        JSON API, в контексте статьи, это протокол, который описывается спекой, доступной по ссылке из статьи. REST скорее архитектурный стиль.
        • +2
          от всем известного REST API?

          1. REST API не бывает.
          2. REST не всем известен. Подавляющее большинство начинает вещать о каких-то там "спецификациях REST" и о том что "POST" это Create а "PUT" это Update, не учитывая такие вещи как "какой метод должен гарантировать идемпотентность операции а какой не очень".
          3. REST это архитектурный стиль WEB-а. В большинстве же API от uniform interface только урлы ресурсов забывая о метаданных ресурсов этих и т.д. Контент тайп хоть делают и то хорошо.
          4. JSON API это намного более конкретная штука. Все реализации между собой похожи, приследуют возможность строить композицию ответов (инклуды) и прочее. Возьмите json api, graphql и т.д. — идеи похожи, реализации просто разные.

          я могу продолжать.

        • +1
          Точно такими же мыслями я руководствовался, когда решил написать похожий инструмент в рамках работы над своим новым проектом. В моем случае скрипт рекурсивно обходит JSON-объект с конфигурацией, и исполняет хуки, сгруппированые в классы-драйверы с одинаковыми интерфейсами. Каждый драйвер овечает за генерацию кода определенного типа. На данный момент у меня их 3:
          — SQL CREATE,
          — JS Pojo Object for server
          — JS Pojo Object for browser
          В результате, описав в конфиге таблицу так
          конфиг
                  {
                      name: "users",
                      singular_name: "user",
                      retention: "SoftDeleteRetention",
                      audit: "SimpleAudit",
                      securify: { sqlWhere: " AND id = ? ", expr: "[ req.user && req.user.getId() || 0 ]" },
                      access_delete: { user: false, csr: true, admin: true },
                      columns: [
                          {
                              name: "id",
                              type: "primaryIdType",
                          },
                          {
                              name: "username",
                              type: "string",
                              nullable: false,
                              validator: "UsernameFieldValidator",
                              access_user:  true,
                              access_csr:   true,
                              access_admin: true,
                              descr: { en: "Username", ru: "Имя пользователя" }
                          },
                          {
                              name: "email",
                              type: "emailType",
                              nullable: false,
                              validator: "EmailFieldValidator",
                              access_user:  true,
                              access_csr:   true,
                              access_admin: true,
                              descr: { en: "Email", ru: "Емайл" }
                          },
                          {
                              name: "password",
                              type: "passwordType",
                              access_user:  { c: true, r: false, u: true  },
                              access_csr:   { c: true, r: false, u: true  },
                              access_admin: { c: true, r: false, u: true  },
                              descr: { en: "Password", ru: "Пароль" }
                          },
                          {
                              name: "password_reset_token",
                              access_user:  false,
                              access_csr:   false,
                              access_admin: false,
                              type: "string",
                              noGui: true
                          },
                          {
                              name: "password_reset_expires",
                              access_user:  { c: false, r: false, u: false  },
                              access_csr:   { c: false, r: true,  u: false  },
                              access_admin: { c: false, r: true,  u: false  },
                              type: "datetime",
                              noGui: true
                          },
                          {
                              name: "addr_line_1",
                              type: "string",
                              access_user:  true,
                              access_csr:   true,
                              access_admin: true,
                              descr: { en: "Address", ru: "Адрес" }
                          },
                          {
                              name: "addr_line_2",
                              type: "string",
                              access_user:  true,
                              access_csr:   true,
                              access_admin: true,
                              descr: { en: "Address Line 2", ru: "Адрес (продолжение)" }
                          },
                          {
                              name: "addr_city",
                              type: "string",
                              access_user:  true,
                              access_csr:   true,
                              access_admin: true,
                              descr: { en: "City", ru: "Город" }
                          },
                          {
                              name: "addr_province",
                              type: "string",
                              access_user:  true,
                              access_csr:   true,
                              access_admin: true,
                              descr: { en: "State/Province", ru: "Регион" }
                          },
                          {
                              name: "addr_postal_code",
                              type: "string",
                              access_user:  true,
                              access_csr:   true,
                              access_admin: true,
                              descr: { en: "Postal/Zip Code", ru: "Индекс" }
                          },
                          {
                              name: "addr_country",
                              type: "string",
                              access_user:  true,
                              access_csr:   true,
                              access_admin: true,
                              descr: { en: "Country", ru: "Страна" }
                          },
                          {
                              name: "is_user",
                              type: "yesNoType",
                              access_user:  { c: false, r: true, u: false  },
                              access_csr:   { c: false, r: true, u: false  },
                              access_admin: { c: true, r: true,  u: true },
                              descr: { en: "Is User", ru: "Пользователь" }
                          },
                          {
                              name: "is_csr",
                              type: "yesNoType",
                              access_user:  { c: false, r: true, u: false  },
                              access_csr:   { c: false, r: true, u: false  },
                              access_admin: { c: true, r: true,  u: true },
                              descr: { en: "Is CSR", ru: "Менеджер" }
                          },
                          {
                              name: "is_admin",
                              type: "yesNoType",
                              access_user:  { c: false, r: true, u: false  },
                              access_csr:   { c: false, r: true, u: false  },
                              access_admin: { c: true, r: true,  u: true },
                              descr: { en: "Is Admin", ru: "Админ" }
                          },
                          {
                              name: "last_active",
                              type: "datetime",
                              access_user:  { c: false, r: true, u: false  },
                              access_csr:   { c: false, r: true, u: false  },
                              access_admin: { c: false, r: true, u: false  },
                              descr: { en: "Last Login", ru: "Последнее подключение" }
                          }
                      ],
                  },
          


          я автоматически получаю
          SQL код для создания таблиц
          CREATE TABLE IF NOT EXISTS ex_users (
           id int AUTO_INCREMENT NOT NULL,
           PRIMARY KEY (id),
           username varchar(255) NOT NULL,
           email varchar(255) NOT NULL,
           password varchar(255) NULL,
           password_reset_token varchar(255) NULL,
           password_reset_expires BIGINT NULL,
           addr_line_1 varchar(255) NULL,
           addr_line_2 varchar(255) NULL,
           addr_city varchar(255) NULL,
           addr_province varchar(255) NULL,
           addr_postal_code varchar(255) NULL,
           addr_country varchar(255) NULL,
           is_user BOOLEAN NULL,
           is_csr BOOLEAN NULL,
           is_admin BOOLEAN NULL,
           last_active BIGINT NULL,
           create_dt BIGINT NULL,
           created_by int NULL,
           update_dt BIGINT NULL,
           updated_by int NULL,
           delete_dt BIGINT NULL,
           deleted_by int NULL
          );
          



          Pojo для сервера
          var User = function(req,o){
              App.Model.call(this, req);
              this.$pojonatorDef = App.Pojonator.tables[0];
              this.$data = {};
              this.Copy( o || {$data:{}} );
          };
          exports.User = User;
          User.prototype = Object.create(App.Model.prototype);
          User.prototype.constructor = User;
          User.prototype.access_delete = {
              user: false,
              csr: true,
              admin: true
          };
          User.prototype.securify = {
              sqlWhere: " AND id = ? ",
              fn: function(req) { return [ req.user && req.user.getId() || 0 ] }
          };
          User.prototype.cols = {
              id : {
              camelCaseName: "id",
              order: 0,
              nullable: false,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              }
          },
              username : {
              camelCaseName: "username",
              order: 1,
              nullable: false,
              validator: "UsernameFieldValidator",
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              email : {
              camelCaseName: "email",
              order: 2,
              nullable: false,
              validator: "EmailFieldValidator",
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              password : {
              camelCaseName: "password",
              order: 3,
              nullable: true,
              access: {
                  user: {
                      c: true,
                      r: false,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: false,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: false,
                      u: true
                  }
              },
              mutator: "PasswordTypeDbMutator"
          },
              password_reset_token : {
              camelCaseName: "passwordResetToken",
              order: 4,
              nullable: true,
              access: {}
          },
              password_reset_expires : {
              camelCaseName: "passwordResetExpires",
              order: 5,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: false,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignDb2Js: function($source){ return ( $source ? new Date( $source ) : undefined )},
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignJs2Db: function($source){ return ( $source ? $source.getTime()   : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              addr_line_1 : {
              camelCaseName: "addrLine_1",
              order: 6,
              nullable: true,
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_line_2 : {
              camelCaseName: "addrLine_2",
              order: 7,
              nullable: true,
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_city : {
              camelCaseName: "addrCity",
              order: 8,
              nullable: true,
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_province : {
              camelCaseName: "addrProvince",
              order: 9,
              nullable: true,
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_postal_code : {
              camelCaseName: "addrPostalCode",
              order: 10,
              nullable: true,
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_country : {
              camelCaseName: "addrCountry",
              order: 11,
              nullable: true,
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              is_user : {
              camelCaseName: "isUser",
              order: 12,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              is_csr : {
              camelCaseName: "isCsr",
              order: 13,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              is_admin : {
              camelCaseName: "isAdmin",
              order: 14,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              last_active : {
              camelCaseName: "lastActive",
              order: 15,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignDb2Js: function($source){ return ( $source ? new Date( $source ) : undefined )},
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignJs2Db: function($source){ return ( $source ? $source.getTime()   : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              create_dt : {
              camelCaseName: "createDt",
              order: 16,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignDb2Js: function($source){ return ( $source ? new Date( $source ) : undefined )},
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignJs2Db: function($source){ return ( $source ? $source.getTime()   : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              created_by : {
              camelCaseName: "createdBy",
              order: 17,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              }
          },
              update_dt : {
              camelCaseName: "updateDt",
              order: 18,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignDb2Js: function($source){ return ( $source ? new Date( $source ) : undefined )},
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignJs2Db: function($source){ return ( $source ? $source.getTime()   : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              updated_by : {
              camelCaseName: "updatedBy",
              order: 19,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              }
          },
              delete_dt : {
              camelCaseName: "deleteDt",
              order: 20,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignDb2Js: function($source){ return ( $source ? new Date( $source ) : undefined )},
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignJs2Db: function($source){ return ( $source ? $source.getTime()   : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              deleted_by : {
              camelCaseName: "deletedBy",
              order: 21,
              nullable: true,
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              }
          }};
          /** @return {number} */
          User.prototype.getId = function() { return this.$data.id };
          /** @param {number} id */
          User.prototype.setId = function(id) {
              this.$data.id !== id  && App.Model.setDirty(this,'id');
              this.$data.id = id;
          };/** @return {string} */
          User.prototype.getUsername = function() { return this.$data.username };
          /** @param {string} username */
          User.prototype.setUsername = function(username) {
              this.$data.username !== username  && App.Model.setDirty(this,'username');
              this.$data.username = username;
          };/** @return {string} */
          User.prototype.getEmail = function() { return this.$data.email };
          /** @param {string} email */
          User.prototype.setEmail = function(email) {
              this.$data.email !== email  && App.Model.setDirty(this,'email');
              this.$data.email = email;
          };/** @return {string} */
          User.prototype.getPassword = function() { return this.$data.password };
          /** @param {string} password */
          User.prototype.setPassword = function(password) {
              this.$data.password !== password  && App.Model.setDirty(this,'password');
              this.$data.password = password;
          };/** @return {string} */
          User.prototype.getPasswordResetToken = function() { return this.$data.password_reset_token };
          /** @param {string} passwordResetToken */
          User.prototype.setPasswordResetToken = function(passwordResetToken) {
              this.$data.password_reset_token !== passwordResetToken  && App.Model.setDirty(this,'password_reset_token');
              this.$data.password_reset_token = passwordResetToken;
          };User.prototype.getPasswordResetExpires = function() { return this.$data.password_reset_expires };
          User.prototype.setPasswordResetExpires = function(passwordResetExpires) {
              this.$data.password_reset_expires !== passwordResetExpires  && App.Model.setDirty(this,'password_reset_expires');
              this.$data.password_reset_expires = passwordResetExpires;
          };/** @return {string} */
          User.prototype.getAddrLine_1 = function() { return this.$data.addr_line_1 };
          /** @param {string} addrLine_1 */
          User.prototype.setAddrLine_1 = function(addrLine_1) {
              this.$data.addr_line_1 !== addrLine_1  && App.Model.setDirty(this,'addr_line_1');
              this.$data.addr_line_1 = addrLine_1;
          };/** @return {string} */
          User.prototype.getAddrLine_2 = function() { return this.$data.addr_line_2 };
          /** @param {string} addrLine_2 */
          User.prototype.setAddrLine_2 = function(addrLine_2) {
              this.$data.addr_line_2 !== addrLine_2  && App.Model.setDirty(this,'addr_line_2');
              this.$data.addr_line_2 = addrLine_2;
          };/** @return {string} */
          User.prototype.getAddrCity = function() { return this.$data.addr_city };
          /** @param {string} addrCity */
          User.prototype.setAddrCity = function(addrCity) {
              this.$data.addr_city !== addrCity  && App.Model.setDirty(this,'addr_city');
              this.$data.addr_city = addrCity;
          };/** @return {string} */
          User.prototype.getAddrProvince = function() { return this.$data.addr_province };
          /** @param {string} addrProvince */
          User.prototype.setAddrProvince = function(addrProvince) {
              this.$data.addr_province !== addrProvince  && App.Model.setDirty(this,'addr_province');
              this.$data.addr_province = addrProvince;
          };/** @return {string} */
          User.prototype.getAddrPostalCode = function() { return this.$data.addr_postal_code };
          /** @param {string} addrPostalCode */
          User.prototype.setAddrPostalCode = function(addrPostalCode) {
              this.$data.addr_postal_code !== addrPostalCode  && App.Model.setDirty(this,'addr_postal_code');
              this.$data.addr_postal_code = addrPostalCode;
          };/** @return {string} */
          User.prototype.getAddrCountry = function() { return this.$data.addr_country };
          /** @param {string} addrCountry */
          User.prototype.setAddrCountry = function(addrCountry) {
              this.$data.addr_country !== addrCountry  && App.Model.setDirty(this,'addr_country');
              this.$data.addr_country = addrCountry;
          };/** @return {boolean} */
          User.prototype.getIsUser = function() { return this.$data.is_user };
          /** @param {boolean} isUser */
          User.prototype.setIsUser = function(isUser) {
              this.$data.is_user !== isUser  && App.Model.setDirty(this,'is_user');
              this.$data.is_user = isUser;
          };/** @return {boolean} */
          User.prototype.getIsCsr = function() { return this.$data.is_csr };
          /** @param {boolean} isCsr */
          User.prototype.setIsCsr = function(isCsr) {
              this.$data.is_csr !== isCsr  && App.Model.setDirty(this,'is_csr');
              this.$data.is_csr = isCsr;
          };/** @return {boolean} */
          User.prototype.getIsAdmin = function() { return this.$data.is_admin };
          /** @param {boolean} isAdmin */
          User.prototype.setIsAdmin = function(isAdmin) {
              this.$data.is_admin !== isAdmin  && App.Model.setDirty(this,'is_admin');
              this.$data.is_admin = isAdmin;
          };User.prototype.getLastActive = function() { return this.$data.last_active };
          User.prototype.setLastActive = function(lastActive) {
              this.$data.last_active !== lastActive  && App.Model.setDirty(this,'last_active');
              this.$data.last_active = lastActive;
          };User.prototype.getCreateDt = function() { return this.$data.create_dt };
          User.prototype.setCreateDt = function(createDt) {
              this.$data.create_dt !== createDt  && App.Model.setDirty(this,'create_dt');
              this.$data.create_dt = createDt;
          };/** @return {number} */
          User.prototype.getCreatedBy = function() { return this.$data.created_by };
          /** @param {number} createdBy */
          User.prototype.setCreatedBy = function(createdBy) {
              this.$data.created_by !== createdBy  && App.Model.setDirty(this,'created_by');
              this.$data.created_by = createdBy;
          };User.prototype.getUpdateDt = function() { return this.$data.update_dt };
          User.prototype.setUpdateDt = function(updateDt) {
              this.$data.update_dt !== updateDt  && App.Model.setDirty(this,'update_dt');
              this.$data.update_dt = updateDt;
          };/** @return {number} */
          User.prototype.getUpdatedBy = function() { return this.$data.updated_by };
          /** @param {number} updatedBy */
          User.prototype.setUpdatedBy = function(updatedBy) {
              this.$data.updated_by !== updatedBy  && App.Model.setDirty(this,'updated_by');
              this.$data.updated_by = updatedBy;
          };User.prototype.getDeleteDt = function() { return this.$data.delete_dt };
          User.prototype.setDeleteDt = function(deleteDt) {
              this.$data.delete_dt !== deleteDt  && App.Model.setDirty(this,'delete_dt');
              this.$data.delete_dt = deleteDt;
          };/** @return {number} */
          User.prototype.getDeletedBy = function() { return this.$data.deleted_by };
          /** @param {number} deletedBy */
          User.prototype.setDeletedBy = function(deletedBy) {
              this.$data.deleted_by !== deletedBy  && App.Model.setDirty(this,'deleted_by');
              this.$data.deleted_by = deletedBy;
          };
          User.prototype.DB_TABLE_NAME = 'ex_users';
          



          Pojo для браузера
          DB.User = function(req,o){
              this.$data = {};
              this.Copy( o || {$data:{}} );
          };
          DB.User.prototype = Object.create(Mdl.prototype);
          DB.User.prototype.constructor = DB.User;
          DB.User.prototype.access_delete = {
              user: false,
              csr: true,
              admin: true
          };
          DB.User.prototype.cols = {
              id : {
              name: "id",
              camelCaseName: "id",
              order: 0,
              jsGuiType: "FormFieldNumber",
              nullable: false,
              descr: {
                  en: "#",
                  ru: "№"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              }
          },
              username : {
              name: "username",
              camelCaseName: "username",
              order: 1,
              jsGuiType: "FormFieldText",
              nullable: false,
              validator: "UsernameFieldValidator",
              descr: {
                  en: "Username",
                  ru: "Имя пользователя"
              },
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              email : {
              name: "email",
              camelCaseName: "email",
              order: 2,
              jsGuiType: "FormFieldText",
              nullable: false,
              validator: "EmailFieldValidator",
              descr: {
                  en: "Email",
                  ru: "Емайл"
              },
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              password : {
              name: "password",
              camelCaseName: "password",
              order: 3,
              jsGuiType: "FormFieldPassword",
              nullable: true,
              descr: {
                  en: "Password",
                  ru: "Пароль"
              },
              access: {
                  user: {
                      c: true,
                      r: false,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: false,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: false,
                      u: true
                  }
              }
          },
              addr_line_1 : {
              name: "addr_line_1",
              camelCaseName: "addrLine_1",
              order: 4,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "Address",
                  ru: "Адрес"
              },
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_line_2 : {
              name: "addr_line_2",
              camelCaseName: "addrLine_2",
              order: 5,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "Address Line 2",
                  ru: "Адрес (продолжение)"
              },
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_city : {
              name: "addr_city",
              camelCaseName: "addrCity",
              order: 6,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "City",
                  ru: "Город"
              },
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_province : {
              name: "addr_province",
              camelCaseName: "addrProvince",
              order: 7,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "State/Province",
                  ru: "Регион"
              },
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_postal_code : {
              name: "addr_postal_code",
              camelCaseName: "addrPostalCode",
              order: 8,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "Postal/Zip Code",
                  ru: "Индекс"
              },
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              addr_country : {
              name: "addr_country",
              camelCaseName: "addrCountry",
              order: 9,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "Country",
                  ru: "Страна"
              },
              access: {
                  user: {
                      c: true,
                      r: true,
                      u: true
                  },
                  csr: {
                      c: true,
                      r: true,
                      u: true
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              is_user : {
              name: "is_user",
              camelCaseName: "isUser",
              order: 10,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "Is User",
                  ru: "Пользователь"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              is_csr : {
              name: "is_csr",
              camelCaseName: "isCsr",
              order: 11,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "Is CSR",
                  ru: "Менеджер"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              is_admin : {
              name: "is_admin",
              camelCaseName: "isAdmin",
              order: 12,
              jsGuiType: "FormFieldText",
              nullable: true,
              descr: {
                  en: "Is Admin",
                  ru: "Админ"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: true,
                      r: true,
                      u: true
                  }
              }
          },
              last_active : {
              name: "last_active",
              camelCaseName: "lastActive",
              order: 13,
              jsGuiType: "FormFieldDatetime",
              nullable: true,
              descr: {
                  en: "Last Login",
                  ru: "Последнее подключение"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              create_dt : {
              name: "create_dt",
              camelCaseName: "createDt",
              order: 14,
              jsGuiType: "FormFieldDatetime",
              nullable: true,
              descr: {
                  en: "Create Date",
                  ru: "Дата создания"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              created_by : {
              name: "created_by",
              camelCaseName: "createdBy",
              order: 15,
              jsGuiType: "FormFieldNumber",
              nullable: true,
              descr: {
                  en: "Created By",
                  ru: "Создал"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              }
          },
              update_dt : {
              name: "update_dt",
              camelCaseName: "updateDt",
              order: 16,
              jsGuiType: "FormFieldDatetime",
              nullable: true,
              descr: {
                  en: "Update Date",
                  ru: "Дата изменения"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              updated_by : {
              name: "updated_by",
              camelCaseName: "updatedBy",
              order: 17,
              jsGuiType: "FormFieldNumber",
              nullable: true,
              descr: {
                  en: "Updated By",
                  ru: "Изменил"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              }
          },
              delete_dt : {
              name: "delete_dt",
              camelCaseName: "deleteDt",
              order: 18,
              jsGuiType: "FormFieldDatetime",
              nullable: true,
              descr: {
                  en: "Delete Date",
                  ru: "Дата удаления"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              },
              assignJs2Js: function($source){ return ( $source? new Date( $source )  : undefined )},
              assignSer2Js: function($source){ return ( $source ? new Date( $source  ): undefined )},
              assignJs2Ser: function($source){ return ( $source ? $source.getTime()   : undefined )}
          },
              deleted_by : {
              name: "deleted_by",
              camelCaseName: "deletedBy",
              order: 19,
              jsGuiType: "FormFieldNumber",
              nullable: true,
              descr: {
                  en: "Deleted By",
                  ru: "Удалил"
              },
              access: {
                  user: {
                      c: false,
                      r: true,
                      u: false
                  },
                  csr: {
                      c: false,
                      r: true,
                      u: false
                  },
                  admin: {
                      c: false,
                      r: true,
                      u: false
                  }
              }
          }};
          /** @return {number} */
          DB.User.prototype.getId = function() { return this.$data.id };
          /** @param {number} id */
          DB.User.prototype.setId = function(id) {
              this.$data.id = id;
          };/** @return {string} */
          DB.User.prototype.getUsername = function() { return this.$data.username };
          /** @param {string} username */
          DB.User.prototype.setUsername = function(username) {
              this.$data.username = username;
          };/** @return {string} */
          DB.User.prototype.getEmail = function() { return this.$data.email };
          /** @param {string} email */
          DB.User.prototype.setEmail = function(email) {
              this.$data.email = email;
          };/** @return {string} */
          DB.User.prototype.getPassword = function() { return this.$data.password };
          /** @param {string} password */
          DB.User.prototype.setPassword = function(password) {
              this.$data.password = password;
          };/** @return {string} */
          DB.User.prototype.getAddrLine_1 = function() { return this.$data.addr_line_1 };
          /** @param {string} addrLine_1 */
          DB.User.prototype.setAddrLine_1 = function(addrLine_1) {
              this.$data.addr_line_1 = addrLine_1;
          };/** @return {string} */
          DB.User.prototype.getAddrLine_2 = function() { return this.$data.addr_line_2 };
          /** @param {string} addrLine_2 */
          DB.User.prototype.setAddrLine_2 = function(addrLine_2) {
              this.$data.addr_line_2 = addrLine_2;
          };/** @return {string} */
          DB.User.prototype.getAddrCity = function() { return this.$data.addr_city };
          /** @param {string} addrCity */
          DB.User.prototype.setAddrCity = function(addrCity) {
              this.$data.addr_city = addrCity;
          };/** @return {string} */
          DB.User.prototype.getAddrProvince = function() { return this.$data.addr_province };
          /** @param {string} addrProvince */
          DB.User.prototype.setAddrProvince = function(addrProvince) {
              this.$data.addr_province = addrProvince;
          };/** @return {string} */
          DB.User.prototype.getAddrPostalCode = function() { return this.$data.addr_postal_code };
          /** @param {string} addrPostalCode */
          DB.User.prototype.setAddrPostalCode = function(addrPostalCode) {
              this.$data.addr_postal_code = addrPostalCode;
          };/** @return {string} */
          DB.User.prototype.getAddrCountry = function() { return this.$data.addr_country };
          /** @param {string} addrCountry */
          DB.User.prototype.setAddrCountry = function(addrCountry) {
              this.$data.addr_country = addrCountry;
          };/** @return {boolean} */
          DB.User.prototype.getIsUser = function() { return this.$data.is_user };
          /** @param {boolean} isUser */
          DB.User.prototype.setIsUser = function(isUser) {
              this.$data.is_user = isUser;
          };/** @return {boolean} */
          DB.User.prototype.getIsCsr = function() { return this.$data.is_csr };
          /** @param {boolean} isCsr */
          DB.User.prototype.setIsCsr = function(isCsr) {
              this.$data.is_csr = isCsr;
          };/** @return {boolean} */
          DB.User.prototype.getIsAdmin = function() { return this.$data.is_admin };
          /** @param {boolean} isAdmin */
          DB.User.prototype.setIsAdmin = function(isAdmin) {
              this.$data.is_admin = isAdmin;
          };DB.User.prototype.getLastActive = function() { return this.$data.last_active };
          DB.User.prototype.setLastActive = function(lastActive) {
              this.$data.last_active = lastActive;
          };DB.User.prototype.getCreateDt = function() { return this.$data.create_dt };
          DB.User.prototype.setCreateDt = function(createDt) {
              this.$data.create_dt = createDt;
          };/** @return {number} */
          DB.User.prototype.getCreatedBy = function() { return this.$data.created_by };
          /** @param {number} createdBy */
          DB.User.prototype.setCreatedBy = function(createdBy) {
              this.$data.created_by = createdBy;
          };DB.User.prototype.getUpdateDt = function() { return this.$data.update_dt };
          DB.User.prototype.setUpdateDt = function(updateDt) {
              this.$data.update_dt = updateDt;
          };/** @return {number} */
          DB.User.prototype.getUpdatedBy = function() { return this.$data.updated_by };
          /** @param {number} updatedBy */
          DB.User.prototype.setUpdatedBy = function(updatedBy) {
              this.$data.updated_by = updatedBy;
          };DB.User.prototype.getDeleteDt = function() { return this.$data.delete_dt };
          DB.User.prototype.setDeleteDt = function(deleteDt) {
              this.$data.delete_dt = deleteDt;
          };/** @return {number} */
          DB.User.prototype.getDeletedBy = function() { return this.$data.deleted_by };
          /** @param {number} deletedBy */
          DB.User.prototype.setDeletedBy = function(deletedBy) {
              this.$data.deleted_by = deletedBy;
          };
          



          — точку в API с контролем доступа

          — Default-форму с контролем доступа

          Код для операций с БД и генерации HTML-форм умеет пользоваться конфигами таблиц, поэтому если в конфиге указано, что поле «csr_comment» недоступно для чтения/записи с ролью 'user', то это поле автоматически пропадает для этой роли из
          — всех HTML-форм
          — всех SELECT, INSERT и UPDATE-операций, инициированных с этой ролью через форму или через API

          Написание скрипта заняло несколько дней, но зато времени сэкономлено гораздо больше.
          • +1
            А ссылка на код велосипеда где?
            • +1
              На данный момент для велосипеда актуализируется документация и специфичный для нашего проекта код выносится из кода велосипеда в код основного проекта. Следите за новостями в нашем блоге :)
            • 0
              Изначально взяли протобаф и не жалеем до сих пор.
              • +1
                Всё-таки, protobuf про сериализацию данных, а JSON API про их стандартизацию.

                Тем не менее, любопытно, на каких платформах вы используете protobuf?
                • 0
                  Мы описали весь API с клиентами и вот уже получили наш стандарт! Сервер у нас частично на GO, частично на PHP. Клиенты на Java (андроид агент), на ObjectivC (клиент под iOS), на QT под Windows, OS X и Linux.
                  • 0
                    Тут тебе с коробки валидация схемы, минимизация трафика и удобные обвертки для работы с протокольными запросами.
                • 0

                  Есть OpenAPI, для типичных кэйсов ОК

                  • +1

                    Заодно можно почти не беспокоится о реализации клиентов на разные языки. Их можно сгенерить swagger-gen-ом.

                    • +1
                      Насчёт клиентов вы правы, но как быть с сервером? Нам, как бекенду, важно не только договориться с клиентом о том, как будут выглядеть сущности в API, но и уметь генерировать эти сущности из моделей. Насколько я понимаю, для сервера swagger-gen может просто нагенерить пустых экшенов.
                      • 0

                        Для сервера так же можно сгенерить стаб контроллера с моделями.

                        • 0
                          Ну, собственно, это я и имел ввиду — генерятся накие DTO без привязки к источникам данных (AR, Doctrine, вот это вот всё), верно?
                          • 0
                            У автора все делается автоматически. В вашем же случае еще предстоит писать код экшенов и моделей, а также маппингов (когда в базе user_id, а отдать нужно userId) разной степени копипастности, как я понимаю.
                            • 0

                              ничего не мешает написать такой же staffold-генератор для openapi.


                              кажется, есть автогенераторы openapi из кода контроллеров.


                              просто человек тут решил сделать свой велосипед. нормально, в принципе. но не взлетит в рамках сообщества.

                    • +1

                      Приятно видеть в mature проекте PHP7, современные компоненты и подходы.


                      1. Вопрос в одну копилку к предыдущим: рассматривали ли вы GraphQL? Поверхностно ощущение, что цели вашей спеки совпадают с направлением развития GraphQL.
                      2. Почему для Doctrine query builder, а не критерии и/или спецификации?
                      3. Какой движок Swagger вы используете?
                      • +1
                        Ну хоть у кого-то взгляд за mature зацепился :)

                        1. Ну, спека-то, всё же, не наша. Смею предположить, что в момент перехода на JSON API вменяемой имплементации GraphQL на PHP не было. Однако, даже если я не прав, в GraphQL смущает другое: параллельное получение данных из разных источников на PHP хоть и возможно, но не без костылей.
                        2. Вариант с билдером показался более универсальным.
                        3. Swagger-UI.
                        • 0
                          А чем плоха эталонная реализация от авторов на ноде?
                          • 0
                            Что вы конкретно имеете ввиду?
                            • 0
                              Имплементацию от самого ФБ
                              • +1
                                Теперь я вас понял.

                                Получается, что запрос клиента обрабатывается GraphQL-сервером на Node, который отправляет запросы к PHP-бекенду, объединяет ответы и отдаёт результат клиенту обратно. В таком подходе лично мне не нравится наличие промежуточного слоя (Node), и, кроме того, есть ощущение, что со временем бизнес-логика начнёт размазываться по JS и PHP.
                                • 0
                                  Будет логика размазываться или нет — зависит только от вас.
                                  Можно иметь несколько GraphQL-серверов с разными наборами кверей или мутаций под разных потребителей и при этом единое рест апи на пхп.
                                  Да и собственно главное преимущество этой реализации — это то что она готова и она наиболее полная.
                                  • +1

                                    На PHP всё равно придётся реализовывать какой-то API.

                                    • 0

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

                                      • 0
                                        Да, но он будет тривиальным, КРУД по сути
                                        • 0

                                          Если бизнес-логика на стороне PHP (а иначе зачем он вообще нужен?), то его API тривиальным не будет, он должен обеспечивать все бизнес-операции, все команды если говорить в терминах CQRS. В большинстве сдучаев GraphQL шлюз, он же фронтенд-сервер (не суть на ноде или нет) будет выполнять роль агрегатора (при необходимости) результатов запросов из нескольких источников, диспетчера мутаций и, иногда, координатора распределенных транзакций.


                                          Всё это почти никак не упрощает PHP-бэкенд, разве что до поры до времени можно забыть о размере и, частично, количестве сообщений между бэкендом и шлюзом поскольку, как правило, они не будут выходить за пределы дата-центра, стойки, а то и физической машины, что позволит до поры до времени не внедрять на стороне бэкенда средства управления клиентом формой ответа (список полей, "джойны") и количеством элементов в коллекциях (фильтрация, пагинация), а также средства пакетной обработки.

                                          • +1

                                            Я вижу тут два варианта:


                                            • либо тупой CRUD, но тогда в GraphQL-ный шлюз со временем неминуемо утечёт часть бизнес-логики, что нежелательно,
                                            • либо то, что описывает уважаемый VolCh, и тут, как мне кажется, перспектива получить "единый API на REST" разобьётся о скалы суровой реальности: разным GraphQL-серверам будет хотеться разных данных и PHP-шный бекенд будет вынужден удовлетворять запросы всех.
                                            • 0

                                              При тупом CRUD изначально получается, что бизнес-логика должна быть не на PHP-шном бекенде — либо на клиенте, либо на GraphQL-шлюзе, либо между ними размазана, а бэкенд лишь http-хранилище данных, пускай и знающий что-то об условиях валидности и целостности данных. Domain driven web database.


                                              PHP-шный бекенд всегда должен удовлетворять запросы всех своих клиентов (не в плане безопасности, а в плане функциональности). Вопрос лишь нужно ли пытаться подстроиться под каждого индивидуально, или достаточно одной, почти всегда заведомо избыточной в каждом конкретном случае, версии API. GraphQL-шлюз в этом отношении лишь минимизирует количество клиентов в идеале до одного, но навскиду есть смысл иметь минимум два — публичный и внутренний для бэкофиса.


                                              Хотя, вот прямо сейчас родилась идея и PHP-шный бекенд обернуть в GraphQL, но без всяких внешних связей и со строгой лимитацией глубины запросов для агрегатов, по сути другая реализация JSON API. А если клиенту нужно что-то большее, то GraphQL-шлюз разворачивает во множество запросов. Но в целом это вроде ничего не меняет — PHP-шный бекенд должен удовлетворять всех клиентов (прямо или через шлюз) по определнию.

                                              • 0
                                                и PHP-шный бекенд будет вынужден удовлетворять запросы всех.

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


                                                У меня есть API для трех платформ и каждой из платформ нужно почти то же самое но не всегда. Это приводит к избыточности и дублированию. Graphql для меня лично является нелпохим решением. Не без проблем но...

                              • 0
                                параллельное получение данных из разных источников на PHP хоть и возможно, но не без костылей

                                а зачем параллельное?
                                • +1
                                  Если клиент хочет получить несколько коллекций сущностей за один вопрос, а мы у себя в коде будем собирать эти коллекции последовательно, в чем профит? Клиенту проще параллельно отправить несколько запросов.
                                  • 0
                                    Нет, так как для того чтобы сделать запрос тоже уходит время.
                                    • +1
                                      Безусловно, но всё слишком сильно зависит от конкретного случая.

                                      Время на «сделать запрос» можно разделить на две составляющие: это время, которое будет потрачено на установление соединение, и время, которое нужно серверному приложению для бутстрапа. Первой составляющей, в общем-то, отчасти можно принебречь, поскольку HTTP/2 поддерживает мультиплексацию, упаковывая несколько запросов в одно соединение.

                                      Так вот, если время, которое приложение потратит на генерацию ответа клиенту, сравнимо с временем, потраченным на сетевое взаимодействие и бутстрап (скажем, API достаёт уже подготовленный ответ из кеша), правда на вашей стороне.
                                      Однако, если получение запрошенных коллекций требует выполнения какой-то сложной бизнес-логики, ситуация перестаёт быть такой однозначной, и тут вполне может выиграть распараллеливание запросов.
                            • 0
                              Ваш следующий API будет graphql. Инфа 146% :) потому что читая я понимал, что вы именно его и пытались сделать
                              • 0

                                А почему не использовали fractal, можно же было расширить

                                • +1
                                  Я не работал с fractal, однако, навскидку, этот проект не выглядит хорошим кандидатом для расширения под наши хотелки:
                                  • fractal, как и многие другие решения, предлагает писать билдеры сущностей (в fractal они именуются трансформерами) — мы же, напротив, хотели уйти от кучи бесполезных классов, внутри которых делается что-нибудь типа
                                    return ['id' => (int) $item->id];
                                  • судя по устройству этих самых трансформеров, fractal умеет превращать модели в некие сущности, но не наборот: если клиент прислал нам некую сущность для сохранения, нам придётся руками преобразовывать её в модель (искать в базе, обновлять поля и т. д.)
                                  • fractal из-за своей архитектуры не умеет и, видимо, не научится делать eager-loading для связей: для lazy-loading они целый синтаксис сделали (довольно приятный, к слову), для eager — делай всё сам.
                                  • 0

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


                                    Ну и при изменениях требований то что казалось удобным (мэппинг напрямую на сущности) резко становится неудобным (сложно впилить нужную фичу).

                                    • +1
                                      Мы преобразовываем json в модели и обратно: таким образом, в коде эндпойнта мы вообще никак не взаимодействуем с сущностями (под сущностями я понимаю те объекты, которые видит клиент нашего API). Кажется, что в данном случае единственное изменение требований, которое сделает такое пребразование неудобным — это отказ JSON API.
                                      Разумеется, есть сложные случаи, когда одна модель должна распасться на несколько сущностей, или, напротив, одна сущность состоит из нескольких моделей — но такие кейсы мы умеем обрабатывать.

                                      Не могли бы вы рассказать в общих чертах, как вы выполняете обратную трансформацию?

                                      Ну и про отказ от бэкенда я вас, честно говоря, не понял :)

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое