Запуск приложения Job Board с использованием Strapi и SwiftUI

В этом руководстве мы создадим iOS-приложение для доски объявлений с использованием SwiftUI и Strapi. Мы узнаем, как расширять коллекции Strapi, добавлять собственные маршруты и обработчики HTTP-запросов (контроллеры). Мы будем использовать пользовательские функции контроллера для обработки загрузки файлов, связанных с определенной записью. Мы добавим функциональность веб-сокетов, чтобы соискатель и рекламодатель могли отправлять друг другу сообщения в режиме реального времени. Мы будем использовать плагин user-permissions для аутентификации каждого подключения к сокету.

Контур

  • Введение
  • Предпосылки
  • Сопряжение SwiftUI со Strapi
  • Настройка проекта Strapi
  • Создание коллекций
  • Расширение маршрутов сбора и контроллеров
  • Настройка сокета и аутентификация
  • Инициализация проекта SwiftUI
  • Установка клиента Socket IO
  • Настройка сетевых сервисов и моделей
  • Поток аутентификации SwiftUI
  • Заключение.

Введение

Доски объявлений появились в Интернете в конце 20-го века, на ранних стадиях развития Интернета. Самая ранняя доска объявлений о вакансиях была разработана компанией NetStart Inc в 1995 году. Платформа позволяла работодателям размещать объявления о вакансиях, а соискателям — искать их и подавать заявки. Для своего времени это был довольно впечатляющий веб-сайт, который, возможно, считался пионером онлайн-досок вакансий. NetStart Inc с годами трансформировалась и в настоящее время известна как CareerBuilder.

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

В настоящее время в Интернете существует множество досок объявлений о трудоустройстве. Большинство организаций и крупных кооперативов имеют доску объявлений на своих веб-сайтах, где они перечисляют открытые вакансии в своей рабочей силе. В этом уроке мы будем создавать приложение для доски объявлений с использованием SwiftUI. Приложение будет приложением для iOS, которое сильно зависит от экземпляра сервера Strapi.

Предпосылки

Интегрированная среда разработки, я использую VS Code, но вы можете использовать другие.

  • Предварительное знание Strapi полезно, но не обязательно — изучите основы Strapi v4.
  • Базовые знания аутентификации веб-токена JSON.

Сопряжение SwiftUI со Strapi

SwiftUI — это набор инструментов пользовательского интерфейса (UI), разработанный Apple в 2019 году. Он основан на языке программирования Apple Swift. SwiftUI упрощает процесс создания пользовательских интерфейсов, предоставляя модель декларативного программирования, которая позволяет разработчикам обрисовывать в общих чертах структуру и поведение компонентов пользовательского интерфейса с помощью краткого и легко читаемого синтаксиса. Кроме того, SwiftUI поддерживает адаптивные макеты пользовательского интерфейса, что позволяет разработчикам учитывать различные размеры, разрешения и ориентации дисплея. Это помогает оптимизировать процесс разработки программного обеспечения для визуально привлекательных пользовательских интерфейсов на платформах Apple.

Соединяя SwiftUI со Strapi, мы можем создавать приложения, которые эффективно взаимодействуют друг с другом, поскольку SwiftUI имеет возможности реактивного пользовательского интерфейса, что означает, что любое изменение данных из нашего бэкенда Strapi будет отображаться практически немедленно с эффективным использованием ресурсов. В дополнение к этому, Strapi относительно прост в использовании и может использоваться для создания расходуемых интерфейсов прикладного программирования (API) за считанные минуты. В сочетании с готовыми компонентами пользовательского интерфейса SwiftUI данные можно быстро извлекать и отображать на всех устройствах в экосистеме Apple. Это сокращает время разработки программного проекта за счет уменьшения объема кода, необходимого для настройки полностью функционирующей системы.

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

Настройка Страпи

Для начала мы начнем с настройки нашего экземпляра сервера Strapi. Strapi сильно зависит от Node. Убедитесь, что он установлен на вашем компьютере для разработки. Мы будем использовать команду npx, которая представляет собой средство запуска пакетов узлов, которое будет выполнять скрипт и формировать новый проект strapi внутри папки проекта в текущем рабочем каталоге.

Откройте терминал или командную строку (cmd/terminal) и выполните следующую команду, чтобы создать каркас сервера Strapi.

npx create-strapi-app@latest backend --quickstart

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

Создание коллекций

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

Коллекция компании

Эта коллекция будет использоваться для реквизитов компании.

  1. Нажмите Конструктор типов контента в разделе «Плагины» на боковой панели навигации.
  2. Нажмите Создать новый тип коллекции.
  3. Введите Company в поле Отображаемое имя и нажмите Продолжить.
  4. Нажмите кнопку Текстовое поле.
  5. Введите n``ame в поле Имя.
  6. Повторите описанные выше шаги и добавьте текстовые поля для address, email, phone, bio и category в коллекцию.
  7. Создайте поле мультимедиа и установите для него одно поле типа мультимедиа. В поле ввода имени введите logo. Это поле будет использоваться для сохранения логотипа компании. Тип логотипа будет связан с загрузками файлов изображений с расширениями, например .jpg и .png.
  8. Создайте поле отношения и внутри представителя типа ввода имени поля компании.
  9. Выберите User(from: users-permission) из раскрывающегося списка и убедитесь, что выбран вариант Company has one user, как показано ниже.

Поле отношения используется для создания связи между двумя таблицами базы данных. В нашем случае мы связываем каждого пользователя с компанией. Мы сможем получить все данные о пользователе из плагина user-permissions. Созданное нами отношение гарантирует, что у каждой компании будет только один пользователь. Репрезентативный столбец в таблице котировок будет использоваться для хранения внешнего ключа, который является идентификатором пользователя. Таким образом, у каждой компании есть один представитель в приложении Job Board.

Нажмите кнопку Сохранить и дождитесь применения изменений.

Коллекция вакансий

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

  1. Нажмите Конструктор типов контента в разделе «Плагины» на боковой панели навигации.
  2. Нажмите Создать новый тип коллекции.
  3. Введите Job в поле Отображаемое имя и нажмите Продолжить.
  4. Создайте текстовые поля для следующих name, description, type, status и environment.
  5. Создайте поле отношения и внутри поля ввода заявления о приеме на работу введите компанию. Выберите компанию из раскрывающегося списка справа. Убедитесь, что выбран параметр company has many jobs, как показано ниже.

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

Коллекция приложений

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

  1. Нажмите Конструктор типов контента в разделе «Плагины» на боковой панели навигации.
  2. Нажмите Создать новый тип коллекции.
  3. Введите Application в поле Отображаемое имя и нажмите Продолжить.
  4. Создайте текстовое поле с именем status.
  5. Добавьте два реляционных поля с именем job, которые ссылаются на коллекцию заданий, и другое поле с именем applicant, которое ссылается на коллекцию пользователей.

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

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

Коллекция сообщений

Этот сборник будет отвечать за сохранение разговоров между представителем компании и соискателем.

  1. Нажмите Конструктор типов контента в разделе «Плагины» на боковой панели навигации.
  2. Нажмите Создать новый тип коллекции.
  3. Введите Message в поле Отображаемое имя и нажмите Продолжить.
  4. Создайте поле JSON с именем texts. В этом поле хранятся все сообщения между собеседниками.
  5. Создайте текстовое поле с именем room. Это поле будет использоваться для хранения конкатенации строк имен пользователей сторон, участвующих в разговоре. Наша реализация гарантирует, что в комнате одновременно могут находиться только два человека.

Расширение коллекции пользователей

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

  1. Нажмите Конструктор типов контента в разделе «Плагины» на боковой панели навигации.
  2. Нажмите «Пользователь», который является последней коллекцией в списке типов коллекций.
  3. В правом верхнем углу нажмите Добавить другое поле.
  4. Нажмите на кнопку Текстовое поле.
  5. Введите first_name в качестве имени поля. Не изменяйте параметр короткого текста по умолчанию.
  6. Повторите описанный выше шаг, чтобы добавить поля для last_name и phone_number.
  7. Создайте медиа-поле одного типа. Назовите его profile. Это поле будет использоваться для хранения изображения профиля пользователя.
  8. Нажмите кнопку Сохранить и дождитесь применения изменений.

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

Расширение функций коллекции

После создания коллекций через интерфейс администратора Strapi отлично справляется с созданием функций Create, Read, Update и Delete, связанных с каждой коллекцией. Однако мы могли бы переопределить функции и добавить собственную логику. Например, мы могли бы добавить некоторую проверку, чтобы гарантировать, что только пользователи, у которых есть компания, связанная с их профилем, могут управлять коллекцией вакансий. На следующих этапах мы собираемся внедрить такие проверки для набора приложений, набора компаний и набора заданий. Мы также переопределим маршруты сбора по умолчанию и добавим наши собственные маршруты и защитим их с помощью плагина Strapi для пользовательских разрешений.

Работа

В этой коллекции мы хотели бы создать специальный маршрут, который позволит компаниям просматривать вакансии, которые они публикуют. Чтобы реализовать это, откройте файл job.js в каталоге контроллеров (./src/api/job/controllers). Добавьте приведенный ниже код в функциональный блок createCoreController.

//./src/api/jobs/controllers/job.js
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::job.job', ({ strapi }) => ({
        async myJobs(ctx) {
            const company = await strapi.db.query('api::company.company').findOne({
                where: { representative: ctx.state.user.id },
                populate: {
                    jobs: {
                        populate: {
                            applications: {
                                populate: {
                                    cv: true,
                                    applicant: {
                                        select: ['username', 'first_name', 'last_name', 'phone_number', 'email', 'id']
                                    }
                                }
                            }
                        }
                    }
                }
            });
            ctx.body = company.jobs;
        },
        async create(ctx) {
            const company = await strapi.db.query('api::company.company').findOne({
                where: { representative: ctx.state.user.id }
            });
            if(company){
    
                    let job = await strapi.entityService.create('api::job.job', {
                        data: {
                            ...ctx.request.body.data,
                            company: company.id
                        }
                    });
    
                    ctx.body = job
        
            }else{
            
              ctx.body = {
              success: false,
              message: "Company not found!"};
              }
        },
        async delete(ctx) {
            const company = await strapi.db.query('api::company.company').findOne({
                where: { representative: ctx.state.user.id }
            });
            const target_job = await strapi.db.query('api::job.job').findOne({
                where: { company: company.id, id: ctx.request.params.id }
            });
            if (target_job != null) {
                const job = await strapi.entityService.delete('api::job.job', target_job.id);
                ctx.body = target_job
            } else {
                ctx.body = {
                    success: false
                }
            }
        }
    }));

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

Пользовательская маршрутизация Поскольку мы добавили функцию myJobs в контроллер, нам нужно сопоставить ее с маршрутом. Это позволит запускать функцию при выполнении HTTP-запроса к маршруту. В каталоге задания в папке маршрутов создайте два файла, как показано ниже. Первый файл будет использоваться для добавления нашего пользовательского маршрута к маршрутизатору сбора заданий. Поскольку код запускается последовательно, сначала будет загружен наш пользовательский маршрут, а затем — маршруты по умолчанию.

Добавьте приведенный ниже код в файл job-1.js. Код определяет конечную точку по адресу localhost:1337/api/jobs/mine. Конечная точка — это HTTP-запрос GET, который активирует функцию myJobs, которую мы создали в контроллере.

//job-1.js
    module.exports = {
        routes: [
                  {   
                      method: 'GET',
                      path: '/jobs/mine',
                      handler: 'job.myJobs'
                  }
        ]
    }

Файл job-2.js будет содержать функцию маршрутизатора коллекции по умолчанию.

//job-2.js
    const { createCoreRouter } = require('@strapi/strapi').factories;
    module.exports = createCoreRouter('api::job.job');

Company

We are going to automatically link the user in the connection to a company. This is happen after signing up. We will use the entity service API to upload a file to the record being created. The file in the company context is the company’s logo. View the code below to see how it will be implemented.

//./src/api/company/controllers/company.js
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::company.company', ({ strapi }) => ({
        async create(ctx) {
            const files = ctx.request.files;
            let company = await strapi.entityService.create('api::company.company', {
                data: {
                    ...ctx.request.body,
                    representative: ctx.state.user.id
                },
                files
            });
            ctx.body = company;
        },
        async myProfile(ctx) {
            const company = await strapi.db.query('api::company.company').findOne({
                where: { representative: ctx.state.user.id },
                populate: { logo: true },
            });
            ctx.body = company;
        }
    }));

Функция myProfile — это пользовательская функция, которая загружает профиль компании по выделенному маршруту. Он запрашивает коллекцию с данными текущего пользователя, а затем предварительно загружает логотип компании. Функция сопоставляется с HTTP-запросом GET, как показано ниже. Конечная точка — localhost:1337/api/companies/me.

//./src/api/company/routes/company-1.js
    module.exports = {
        routes: [{ 
                method: 'GET',
                path: '/companies/me',
                handler: 'company.myProfile',
            }]
    }

Приложения

В этой коллекции мы хотим иметь возможность обрабатывать загрузки резюме и связывать их с текущим пользователем. Мы также хотим инициировать разговор после того, как заявка будет обновлена ​​​​до статуса «принято» представителем компании. Мы также реализуем пользовательскую функцию контроллера, которая позволит пользователю загружать все свои приложения. Функция будет называться mine и будет использовать API механизма запросов для получения всех приложений, связанных с текущим пользователем. Он также предварительно загрузит вакансию, на которую подали заявку, файл, который они прикрепили к заявке, название компании, создавшей вакансию, и ее логотип.

//./src/api/application/controllers/application.js
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::application.application', ({ strapi }) => ({
        async create(ctx) {
            const files = ctx.request.files;
            let application = await strapi.entityService.create('api::application.application', {
                data: {
                    ...ctx.request.body,
                    applicant: ctx.state.user.id
                },
                files
            });
            ctx.body = application
        },
        async update(ctx) {
            const updated_application = await strapi.entityService.update('api::application.application', ctx.request.params.id, {
                data: ctx.request.body
            });
            if (ctx.request.body.status == "accepted") {
                let application = await strapi.entityService.create('api::message.message', {
                    data: {
                        room: `${ctx.state.user.username}_${ctx.request.body.job}_${ctx.request.body.applicant_username}`,
                        texts: [
                            {
                                source: 0,
                                text: "Application accepted",
                                id: Date.now()
                            }
                        ]
                    }
                });
            }
            ctx.body = updated_application;
        },
        async mine(ctx) {
            const applications = await strapi.db.query('api::application.application').findMany({
                where: { applicant: ctx.state.user.id },
                orderBy: { id: 'DESC' },
                populate: {
                    job: {
                        populate: {
                            company: {
                                populate: {
                                    logo: true
                                }
                            }
                        }
                    },
                    cv: true
                },
            });
            ctx.body = applications;
        }
    }));

Пользовательская функция с именем mine связана с конечной точкой localhost:1337/api/applications/mine с помощью HTTP-запроса GET. Как и раньше, сначала загружаются пользовательские маршруты, а затем маршруты сбора по умолчанию.

//./src/api/application/routes/routes-1.js
    module.exports = {
        routes: [
            { 
                method: 'GET',
                path: '/applications/mine', 
                handler: 'application.mine',
            }  
        ]
    }

Мы можем подтвердить, были ли распознаны наши маршруты, выполнив приведенную ниже команду в командной строке (terminal/cmd).

npm run strapi routes:list
    # OR
    yarn strapi routes:list

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

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

Настройка сокета и аутентификация

Наше приложение для поиска работы будет иметь функцию обмена сообщениями, которая позволит соискателю и представителю компании общаться. Эта функция будет опираться на серверную библиотеку socket.IO, которая обеспечит модель двунаправленной связи, управляемой событиями, что позволит нам создать эту функцию обмена сообщениями в реальном времени. Используйте приведенную ниже команду, чтобы установить библиотеку в файле package.json нашего проекта Strapi.

npm install socket.io
    # OR
    yarn add socket.io

Сокет должен быть создан до запуска сервера. Мы прикрепим его к адресу нашего экземпляра и номеру порта. Откройте ./src/index.js, файл содержит функции, которые запускаются до запуска приложения Strapi. Мы собираемся добавить наш код в функциональный блок bootstrap. Мы не будем указывать объект Cross-Origin Resource Sharing (CORS), чтобы соединения можно было выполнять с любого адреса и порта. Если вы ожидаете подключения из одного известного источника, рекомендуется добавить его адрес. Ограничение адресов CORS помогает предотвратить несанкционированный доступ к конфиденциальным данным и ресурсам на сервере. Только разрешенные домены могут отправлять запросы к серверу из разных источников.

В функциональном блоке начальной загрузки мы напрямую вызываем и инициализируем установленную нами библиотеку socket.io. Затем мы указываем тип запроса, который будет использоваться транспортным методом HTTP с длинным опросом. Прежде чем разрешить клиенту подключаться, мы сначала проверяем его токен аутентификации. Токен содержит идентификатор пользователя в качестве полезной нагрузки. Поэтому при декодировании токена будет получен объект, содержащий , что позволит нам получить данные пользователя из базы данных с помощью API entityService. Когда пользователь успешно извлекается из базы данных, мы сохраняем его данные в соединении сокета, иначе соединение сокета не будет выполнено.

//./src/index.js
    module.exports = {
      
    bootstrap({ strapi }) {
    let interval;
    let io = require('socket.io')(strapi.server.httpServer, {
          cors: {
            origin: "*",
            methods: ["GET", "POST"]
          }
    });
    
    io.use(async (socket, next) => {
    try {
    
      //Socket Authentication
      const result = await strapi.plugins['users-permissions'].services.jwt.verify(socket.handshake.auth.token);
      const user = await strapi.entityService.findOne('plugin::users-permissions.user', result.id, {
    fields: ['first_name', 'last_name', 'email', 'id', 'username', 'phone_number']
            });
            //Save the User to the socket connection
            socket.user = user;
            next();
          } catch (error) {
            console.log(error);
          }
    
        }).on('connection', function (socket) {
          if (interval) {
            clearInterval(interval);
          }
          interval = setInterval(async () => {
            try {
              const entries = await strapi.entityService.findMany('api::message.message', {
                filters: {
                  $or: [
                    {
                      room: {
                        $endsWith: `_${socket.user.username}`,
                      }
                    },
                    {
                      room: {
                        $startsWith: `${socket.user.username}_`,
                      }
                    },
                  ],
                },
                sort: { createdAt: 'DESC' },
                populate: { texts: true },
              });
    
              io.emit('messages', JSON.stringify({ "payload": entries })
              ); // This will emit the event to all connected sockets
    
    
            } catch (error) {
              console.log(error);
            }
    
          }, 2500);
    
          socket.on('send_message', async (sent_message) => {
    
            const message = await strapi.db.query('api::message.message').findOne({
              where: { room: sent_message.room },
              populate: { texts: true },
            });
    
    
            let new_text = message.texts;
            new_text.push(
              {
                "text": sent_message.text,
                "source": socket.user.id,
                "created": new Date().getTime(),
                "id": generateUUID()
              }
            )
    
            const entry = await strapi.db.query('api::message.message').update({
              where: { room: sent_message.room },
              data: {
                texts: new_text,
              },
            });
    
     io.to(sent_message.room).emit("room_messages", JSON.stringify({ message: entry }));
          });
    
          socket.on('join_room', async (sent_message) => {
            socket.join(sent_message.room);
            const entry = await strapi.db.query('api::message.message').findOne({
              where: { room: sent_message.room },
              populate: { texts: true },
            });
    
            io.to(sent_message.room).emit("room_messages", JSON.stringify({ message: entry }));
    
          });
    
          socket.on('exit_room', (message) => {
            socket.leave(message.room);
          });
    
          socket.on('disconnect', () => {
            clearInterval(interval);
            console.log('user disconnected');
          });
    
        });
        return strapi
      },
    };

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

В том же функциональном блоке событий подключения к сокету мы транслируем все доступные сообщения каждые 2,5 секунды, используя функцию setInterval. Мы используем entityService API для фильтрации записей на основе имени пользователя клиента. Поскольку это событие транслируется на каждое устройство, фильтрация позволяет нам показывать клиенту только наиболее релевантные сообщения. В событии отключения мы очищаем интервал, чтобы предотвратить ненужное использование ресурсов и освободить память экземпляра сервера.

Инициализация проекта SwiftUI

Нам нужно установить Xcode на нашу машину для разработки, чтобы начать использовать SwiftUI. Поскольку SwiftUI нацелен только на платформу Apple, нам нужно использовать Mac. Это позволит нам эмулировать такие устройства, как iPhone, iPad и Apple Watch. Вы можете загрузить Xcode через App Store Mac или его веб-сайт.

После того, как вы успешно установили его и открыли, вы увидите экран ниже. Нажмите «Создать новый проект Xcode», выберите приложение iOS и присвойте ему имя продукта JobBoard.

Настройка сокета ввода/вывода

Мы собираемся установить быстрый клиентский пакет socket io в наш проект iOS. Чтобы начать, нажмите кнопку файла на панели инструментов окна Xcode. Затем нажмите «Добавить новые пакеты» из появившегося списка.

Пакет, который мы собираемся установить, имеет открытый исходный код и активно поддерживается разработчиками в socket io. Мы собираемся использовать ссылку репозитория GitHub пакета, чтобы указать Xcode, откуда он должен получить исходный код для пакета.

В модальной панели поиска вставьте ссылку на репозиторий github socket io и нажмите клавишу ввода, Xcode попытается разрешить адрес, и после завершения будет отображен файл README.md. Нажмите «Добавить пакет» в правом нижнем углу модального окна, чтобы завершить установку пакета.

Настройка сетевых служб и моделей

Мы собираемся создать классы, которые будут использоваться для запросов HTTP и сокетов. Создайте папку с именем Helpers и создайте в ней следующие файлы NetworkService, AuthPersistor, NetworkModels и SocketService. Эти файлы будут иметь расширение .swift. По умолчанию Xcode не показывает расширение файла в файловой структуре проекта, но показывает логотип типа файла перед именем файла.

Поток аутентификации

В папке Helpers мы создаем файл, который будет использоваться для сохранения токена Jwt, который мы получаем от экземпляра strapi во время аутентификации. Мы будем использовать функцию связки ключей iOS для обеспечения постоянства. Связка ключей в основном используется для хранения конфиденциальной информации, такой как пароли и ключи аутентификации. Связка ключей — это зашифрованная база данных, которая используется устройствами Apple для хранения паролей. База данных блокируется, когда устройство заблокировано, и разблокируется, когда устройство разблокировано. Это гарантирует, что полученный нами токен безопасен и не может быть доступен посторонним лицам.

Мы собираемся создать класс, который будет содержать функции, которые помогут нам получить доступ к Keychain. Мы пометим его как окончательный, чтобы его нельзя было переопределить или изменить. Затем мы инициализируем конструктор статического класса с именем standard. Ключевое слово static гарантирует, что переменная принадлежит типу, а не конкретному экземпляру этого типа. Это означает, что каждый экземпляр этого класса будет совместно использовать объект с именем стандарт, а не каждый будет определять свой собственный.

//Helpers/AuthPersistor.swift
    import Foundation
    import Combine
    
    final class KeychainHelper {
        
        static let standard = KeychainHelper()
        private init() {}
        
        func save(_ data: Data, service: String, account: String) {
    
            let query = [
                kSecValueData: data,
                kSecAttrService: service,
                kSecAttrAccount: account,
                kSecClass: kSecClassGenericPassword
            ] as CFDictionary
    
            // Add data in query to keychain
            let status = SecItemAdd(query, nil)
    
            if status == errSecDuplicateItem {
                // Item already exist, thus update it.
                let query = [
                    kSecAttrService: service,
                    kSecAttrAccount: account,
                    kSecClass: kSecClassGenericPassword,
                ] as CFDictionary
    
                let attributesToUpdate = [kSecValueData: data] as CFDictionary
    
                // Update existing item
                SecItemUpdate(query, attributesToUpdate)
            }
        }
        
        func read(service: String, account: String) -> Data? {
            
            let query = [
                kSecAttrService: service,
                kSecAttrAccount: account,
                kSecClass: kSecClassGenericPassword,
                kSecReturnData: true
            ] as CFDictionary
            var result: AnyObject?
            SecItemCopyMatching(query, &result)
            return (result as? Data)
        }
        
        func delete(service: String, account: String) {
            let query = [
                kSecAttrService: service,
                kSecAttrAccount: account,
                kSecClass: kSecClassGenericPassword,
                ] as CFDictionary
            // Delete item from keychain
            SecItemDelete(query)
        }
    }
    
    extension KeychainHelper {
        func save<T>(_ item: T, service: String, account: String) where T : Codable {        
            do {
                // Encode as JSON data and save in keychain
                let data = try JSONEncoder().encode(item)
                save(data, service: service, account: account)
            } catch {
                assertionFailure("Fail to encode item for keychain: \(error)")
            }
        }
        
    func read<T>(service: String, account: String, type: T.Type) -> T? where T : Codable {
            // Read item data from keychain
            guard let data = read(service: service, account: account) else {
                return nil
            }
            // Decode JSON data to object
            do {
                let item = try JSONDecoder().decode(type, from: data)
                return item
            } catch {
                assertionFailure("Fail to decode item for keychain: \(error)")
                return nil
            }
        }
    }

Класс содержит три функции, а именно чтение, удаление и сохранение. Каждая функция использует CFDictonaries для представления данных, которые могут храниться в базе данных Keychain. CFDictonaries — это пары ключ-значение, подобные NSDictionary Swift. Функция сохранения используется для опрокидывания предметов. Мы добавили проверку для обработки случаев, когда ключ уже существует. Значения, если существующие ключи будут обновлены.

Затем мы расширили функциональность класса, чтобы обслуживать кодируемые и декодируемые объекты JSON, используя ключевое слово extension. Ключевое слово добавляет новую функциональность к существующим классам, структурам, перечислениям и типам протоколов. Он в основном используется для расширения типов данных, к которым у нас нет прямого доступа. Чтобы добавленная функциональность работала безупречно, объект, передаваемый функциям чтения и сохранения, должен соответствовать кодируемому протоколу, который преобразует объекты в формат JSON, который можно легко сохранить в базе данных цепочки для ключей. Мы добавили исключение обработки ошибок, которое предотвратит сбой нашего приложения, если переданный объект не соответствует указанному протоколу.

Мы будем использовать модели данных для структурирования данных, полученных от сервера страпи. В каталоге проекта создайте новую папку с именем Models и создайте следующие быстрые файлы: Job, JobApplication, Company, Message и User.

Файл Job устанавливает структуру, которая соответствует как кодируемому, так и идентифицируемому протоколам. Именованная структура job содержит два инициализатора, один из которых будет использоваться для декодирования данных JSON из strapi для построения и экземпляра структуры с использованием извлеченных данных. Инициализатор содержит ключи декодера JSON, которые будут использоваться для извлечения глубоко внедренных объектов JSON.

//Models/Job.swift
    struct Job : Codable, Identifiable{
        
    var id : Int
    var name : String
    var description : String
    var company : Company? = nil
    var type : String //Contract/Long Term/Short Term/Internship/Consultancy
    var environment : String //Remote/Semi-remote/In-Office
    var status : String
        
    init(id: Int, name : String, description: String, type: String, environment: String, status: String){
     self.id = id
     self.name = name
     self.description = description
     self.type = type
     self.environment = environment
     self.status = status
    }
            
    private enum JobDataKeys: String, CodingKey {
     case id = "id",attributes = "attributes"
    enum AttributeKeys : String, CodingKey {
     case name = "name",
      description = "description",
      company = "company",
      type="type",
      environment = "environment", 
      status = "status"
                
     enum CompanyKey : String, CodingKey{
        case data = "data"
        enum CompanyDataKeys : String, CodingKey {
        case id = "id", attributes = "attributes"
        enum CompanyDataAttributesKeys : String, CodingKey{
        case address = "address",
            bio = "bio",
          category = "category",
            email = "email",
             name = "name", 
            phone = "phone",
             logo = "logo"
                            
     enum CompanyLogoKey : String, CodingKey{
     case data = "data"                    
     enum CompanyLogoAttributes : String, CodingKey {
     case attributes = "attributes"
     enum CompanyLogoAttributeKeys : String, CodingKey {
     case url = "url", formats = "formats"
     enum CompanyLogoAttributeFormatsKeys : String, CodingKey {
      case large = "large", 
      medium = "medium",
      small = "small", 
      thumbnail = "thumbnail", 
      url = "url"
      enum CompanyLogoFormartsLarge: String, CodingKey{case url = "url" }
      enum CompanyLogoFormartsThumbnail: String, CodingKey{case url = "url"}
      enum CompanyLogoFormartsSmall: String, CodingKey{case url = "url"}
      enum CompanyLogoFormartsMedium: String, CodingKey{case url = "url"}
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }

Файл Company.swift определяет структуру, которая определяет, как информация о компании будет структурирована в нашем приложении Job Board. В приведенном ниже файле показано, как определена структура. Структуры логотипа компании и компании соответствуют кодируемым протоколам, что гарантирует их преобразование в формат JSON и из него.

//Company.swift
    import Foundation
    struct Company : Codable{
        var id: Int
        var name : String
        var phone : String
        var email : String
        var address : String
        var category : String //Tech/Pharma/Transport/NGO/Finance
        var bio: String
        var logo : CompanyLogo?
        init(id: Int, name: String, phone: String, email: String, address: String, category: String, bio: String, logo: CompanyLogo?) {
            self.id = id
            self.name = name
            self.phone = phone
            self.email = email
            self.address = address
            self.category = category
            self.bio = bio
            self.logo = logo
        }
    }
    
    struct CompanyLogo : Codable{
        var url : String
        var thumbnail : String
        var small : String
        var medium : String
        var large : String
        init(url: String, thumbnail: String, small: String, medium: String, large: String) {
            self.url = url
            self.thumbnail = thumbnail
            self.small = small
            self.medium = medium
            self.large = large
        }
    }

Модель пользователя

Этот файл определяет структуру, которая будет использоваться для представления информации о нашем пользователе. Сведения о пользователе могут быть преобразованы в JSON и обратно, поскольку структура соответствует протоколу Codable. Файл также содержит структуру AuthState, которая будет прослушивать изменения в состояниях аутентификации, используя систему PubSub Swift. В структуре также используется созданный нами класс KeyChainHelper. Когда пользователь проходит аутентификацию, его данные сохраняются в базе данных цепочки ключей. Мы храним его там, потому что токен jwt включен в пользовательскую структуру.

//Models/User.swift
    import Foundation
    import Combine
    
    struct User: Codable{
        var username : String
        var id : Int
        var phone_number : String
        var email : String
        var first_name : String
        var last_name : String
        var token : String
        var profile : ProfileImage? = nil
        var role : Role? = nil
    }
    
    struct ProfileImage : Codable {
        var small : String = ""
        var medium : String = ""
        var large : String = ""
        var thumbnail : String = ""
        var url : String = ""
    }
    
    struct Role: Codable{
        var name : String
        var description: String
        private enum RoleKeys : String, CodingKey {
            case name = "name", description = "description"
        }
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: RoleKeys.self)
            self.name = try container.decode(String.self, forKey: .name)
            self.description = try container.decode(String.self, forKey: .description)
        }
    }
    
    struct AuthState{
        static let Authenticated = PassthroughSubject<Bool, Never>()
        static let Company = PassthroughSubject<Bool, Never>()
        static func IsAuthenticated() -> Bool {
        let user = KeychainHelper.standard.read( 
            service: "strapi_job_authentication_service", 
            account: "strapi_job_app", 
            type: User.self)
            NetworkService.current_user = user
            return user != nil
        }
    
      static func IsCompany() -> Bool {
      let company = KeychainHelper.standard.read( 
        service: "strapi_job_company_service",
        account: "strapi_job_app", 
        type: MyApplicationJobCompany.self)
        NetworkService.company = company
        return company != nil
        }
    }

Модели сетевого отклика

Поскольку SwiftUI — язык со статической типизацией, перед компиляцией нам необходимо объявить переменные типы данных. Нам также нужно преобразовать данные с сервера stripi в типы данных swiftUI. Мы создадим файл, который будет обрабатывать JSON-анализ после успешного сетевого вызова. Файл будет содержать структуры, которые можно использовать в запросах POST и GET. Он также содержит структуры загрузки файлов для различных типов файлов.

//NetworkModels.swift
    import Foundation
    import PhotosUI
    import PDFKit
    
    struct BulkJobServerResponse: Decodable {    
     var data : [Job]
     enum DataKeys: CodingKey {
      case data
     } 
     init(from decoder: Decoder) throws {
      let container = try decoder.container(keyedBy: DataKeys.self)   
      self.data = try container.decode([Job].self, forKey: .data)
     }
    }
    
    struct AuthenticationResponse :  Codable{
        
     var user : User
     enum AuthResponseKeys: String, CodingKey {
      case jwt = "jwt", user = "user"
       enum UserDetailsKeys : String, CodingKey {
         case id = "id", username = "username", email = "email", first_name = "first_name", last_name = "last_name", phone_number = "phone_number"
       }
     }
        
     init(from decoder: Decoder) throws {
      let authReponseContainer = try decoder.container(keyedBy: AuthResponseKeys.self)
      let userDetailsContainer = try authReponseContainer.nestedContainer(keyedBy: AuthResponseKeys.UserDetailsKeys.self, forKey: .user)
      let id = try userDetailsContainer.decode(Int.self, forKey: .id)
      let phone_number = try userDetailsContainer.decode(String.self, forKey: .phone_number)
      let username = try userDetailsContainer.decode(String.self, forKey: .username)
      let first_name = try userDetailsContainer.decode(String.self, forKey: .first_name)
      let last_name = try userDetailsContainer.decode(String.self, forKey: .last_name)
      let email = try userDetailsContainer.decode(String.self, forKey: .email)
      let jwt = try authReponseContainer.decode(String.self, forKey: .jwt)
      self.user = User(username: username, id: id, phone_number: phone_number, email: email, first_name: first_name, last_name: last_name, token: jwt )
        }
    }
    
    struct UploadImage {
        let key: String
        let filename: String
        let data: Data
        let mimeType: String
        init?(withImage image: UIImage, forKey key: String) {
            self.key = key
            self.mimeType = "image/jpeg"
            self.filename = "imagefile.jpg"
            guard let data = image.jpegData(compressionQuality: 0.7) else { return nil }
            self.data = data
        }
    }
    
    struct UploadPDF {
        let key: String
        let filename: String
        let data: Data
        let mimeType: String
        init?(withPDF pdfdoc: PDFDocument, forKey key: String) {
            self.key = key
            self.mimeType = "application/pdf"
            self.filename = "document.pdf"
            self.data = pdfdoc.dataRepresentation()!
        }
    }

Аутентификация

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

Когда выбран тип пользователя компании, мы сразу же создаем компанию и связываем ее с вновь созданным пользователем. Затем они могут изменить данные компании на вкладке профиля после аутентификации.

//Register Function
    func register(first_name:String, last_name: String, username: String,email:String, phone:String, password: String, completion: @escaping (User?) -> ()  ){
            
    guard  let url = URL(string: "\(authentication_url)/local/register")  else {
        completion(nil)
        fatalError("Missing URL") 
    }
            
    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
    let parameters: [String: Any] = [
                "username": username,
                "password": password,
                "email" : email,
                "first_name": first_name,
                "last_name": last_name,
                "phone_number": phone
            ]
    do {
      // convert parameters to Data and assign dictionary to httpBody of request
      urlRequest.httpBody = try JSONSerialization.data(withJSONObject: parameters)
      print(urlRequest)
    } catch let error {
      assertionFailure(error.localizedDescription)
      completion(nil)
      return
    }
            
    let dataTask = URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in
    
    if let error = error {
     print("Request error: ", error)
     completion(nil)
     return
    }
     // ensure there is data returned
    guard let responseData = data else {
     assertionFailure("nil Data received from the server")
     completion(nil)
     return
    }
    
    do {
    
    let loaded_user = try JSONDecoder().decode(AuthenticationResponse.self, from: responseData)
    
    KeychainHelper.standard.save(loaded_user.user, service: "strapi_job_authentication_service",account: "strapi_job_app")
    
    NetworkService.current_user = loaded_user.user
    completion(loaded_user.user)
                    
    } catch let DecodingError.dataCorrupted(context) {
     print(context)
     completion(nil)
    } catch let DecodingError.keyNotFound(key, context) {
     print("Key '\(key)' not found:", context.debugDescription)
     print("codingPath:", context.codingPath)
     completion(nil)
    } catch let DecodingError.valueNotFound(value, context) {
     print("Value '\(value)' not found:", context.debugDescription)
     print("codingPath:", context.codingPath)
     completion(nil)
    } catch let DecodingError.typeMismatch(type, context)  {
     print("Type '\(type)' mismatch:", context.debugDescription)
     print("codingPath:", context.codingPath)
     completion(nil)
    } catch let error {
     assertionFailure(error.localizedDescription)
     completion(nil)
     }
     }
     dataTask.resume()
    }

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

Контекст компании

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

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

Вакансии компании

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

Сокет связи

Нам нужно использовать сокет-соединение, которое мы создали с помощью Socket IO на сервере stripi. Ниже приведено простое определение класса быстрой клиентской стороны, которое включает аутентификацию. Класс используется для облегчения обмена сообщениями в режиме реального времени между заявителем и представителем компании. Он также содержит функции, которые помогают нам в различных функциях сокета io, например, прослушивание трансляций, присоединение к комнатам и отправка событий.

//SocketService.swift
    final class SocketService : ObservableObject{
        private var manager = SocketManager(socketURL: URL(string: "ws://127.0.0.1:1337")!, config: [ .compress])
        @Published var socket_messages : [SocketMessage] = []
        @Published var room : SocketMessage = SocketMessage(id: 0, room: "", texts: [])
        let socket : SocketIOClient
        init(){
            self.socket = manager.defaultSocket
            self.socket.on(clientEvent: .connect, callback: {data, ack in print("Connected") })
            self.socket.on("messages") {data, ack in
                guard let cur = data[0] as? String else { return }
                let jsonObjectData = cur.data(using: .utf8)!
                do {
                    let candidate  =  try JSONDecoder().decode(
                        MM.self,
                        from: jsonObjectData
                    )
                    self.socket_messages = candidate.payload
                } catch let DecodingError.dataCorrupted(context) {
                    print(context)
                    self.socket_messages = []
                } catch let DecodingError.keyNotFound(key, context) {
                    print("Key '\(key)' not found:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.socket_messages = []
                } catch let DecodingError.valueNotFound(value, context) {
                    print("Value '\(value)' not found:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.socket_messages = []
                } catch let DecodingError.typeMismatch(type, context)  {
                    print("Type '\(type)' mismatch:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.socket_messages = []
                } catch let error {
                    assertionFailure(error.localizedDescription)
                    self.socket_messages = []
                }            
            }
            self.socket.on("room_messages") {data, ack in
                guard let cur = data[0] as? String else { return }
                let jsonObjectData = cur.data(using: .utf8)!
                do {
                    let room_details  =  try JSONDecoder().decode(SocketMessage.self,from: jsonObjectData)
                    self.room = room_details
                } catch let DecodingError.dataCorrupted(context) {
                    print(context)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                } catch let DecodingError.keyNotFound(key, context) {
                    print("Key '\(key)' not found:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                } catch let DecodingError.valueNotFound(value, context) {
                    print("Value '\(value)' not found:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                } catch let DecodingError.typeMismatch(type, context)  {
                    print("Type '\(type)' mismatch:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                } catch let error {
                    assertionFailure(error.localizedDescription)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                }
            }
            socket.connect(withPayload: ["token": NetworkService.current_user!.token])
        }
        
        func sendMesage(room_name: String, message : String) {
            self.socket.emit("send_message", ["room": room_name, "text": message])
        }
        
        func joinRoom(room_name: String){
            self.socket.emit("join_room", ["room": room_name])
        }
        
        func exitRoom(room_name: String){
            self.socket.emit("exit_room", ["room": room_name])
        }
    }

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

Мы используем соединение через сокет в двух представлениях, а именно MessageView.swift и MessageDetail.swift. В приведенном ниже коде показано, как мы структурировали MessageView.swift. Поскольку класс SocketService соответствует протоколу [**ObservableObject**](https://developer.apple.com/documentation/combine/observableobject), мы должны использовать оболочку свойства @StateObject каждый раз, когда создаем его экземпляр. Поскольку мы настроили сервер на передачу сообщений каждые 2,5 секунды, пользовательский интерфейс будет обновляться после получения новых изменений.

//MessageView.swift
    import SwiftUI
    import SocketIO
    
    struct MessageView: View {
        @StateObject var service = SocketService()
        static let tag: String? = "MessageView"
        var body: some View {
            NavigationView{
                List(){
                    ForEach(service.socket_messages){message in
                        NavigationLink(destination: MessageDetailView(
                            socketMessage: message,
                            socket: service
                        )) {
                            Text(message.receiver)
                        }
                    }
                }.navigationTitle("My Messages")
            }
        }
    }

Работа

После того, как пользователь войдет в систему, его приветствует список доступных вакансий. Затем они могут щелкнуть объявление о своей предпочтительной вакансии и загрузить свое резюме. Сразу же после загрузки представления мы делаем вызов API GET для конечной точки localhost:1337/api/jobs, используя объект NetworkService в модификаторе представления onAppear. Поскольку мы уже вошли в систему, объект автоматически добавит токен в заголовки запроса.

//HomeView.swift
    import SwiftUI
    
    struct HomeView: View {
        private var network = NetworkService()
        static let tag: String? = "HomeView"
        @State private var jobs : [Job] = []
        var body: some View {
            NavigationView{
                List(jobs) { job in
                    NavigationLink {
                        DetailView(job: job)
                    } label: {
                        JobCard(job: job)
                    }
                }.onAppear{
                    network.listJobs{fetched_jobs in
                        jobs = fetched_jobs
                    }
                    network.loadMyCompanyProfile{company_profile in
                        if company_profile != nil{
                          KeychainHelper.standard.save(company_profile, service: "strapi_job_company_service", account: "strapi_job_app")
                            NetworkService.company = company_profile
                            AuthState.Company.send(true)
                        }
                    }
                    
                }
                .navigationTitle("Jobs")
            }}
    }
    
    struct HomeView_Previews: PreviewProvider {
        static var previews: some View {
            HomeView()
        }
    }

Поскольку мы установили переменную current_user как статическую при определении класса NetworkService, все экземпляры класса будут иметь токен jwt, который хранится в структуре current_user. Структура обновляется с помощью pubs во время аутентификации и де-аутентификации.

Заключение

В этом руководстве мы разработали приложение Job Board на базе Strapi и SwiftUI. Мы рассмотрели, как расширить функциональность коллекций по умолчанию. Мы добавили настраиваемые маршруты в коллекции stripi и подключили Socket IO к экземпляру сервера stripi, чтобы разрешить подключения в реальном времени.

Вы можете скачать исходный код из следующих репозиториев:

  1. SwiftUI Фронтенд
  2. Страпи Бэкенд