Swift: Шаблонный бэкенд с использованием Vapor

В мобильную разработку приходят различными путями. Некоторые рождаются с девайсом в руках, других ведет извилистая дорога вдоль серверов, майнфреймов, дестопных приложений. Но каждый кто в нее попадает ощущает свою незащищенность с тыла, если нет надежного партнера в лице бэкенд –разработчика. И, буквально, каждый мобильщик ожидает, что необходимый API будет готов хотя бы за один спринт, до того, как в нем возникнет необходимость. Конечно же, мир IT разработки редко допускает такую роскошь — за нее требуется бороться с ПМ и бизнес-аналитиком. К тому же не редки ситуации, когда то, что должно быть сделано «на вчера», будет готово «на послезавтра». Те кто имеют достаточно опыта как в наземном, так и подземном мире — берут инициативу с свои руки, и сами предлагают клиент-серверный интерфейс.
Для мобильного мира C# и Java — падения из рая в ад — это довольно естественный процесс, поскольку присущие им платформы изначально целились на поддержку темных сил бэкенда. То ли дело Swift — познавшему небо — не легко дается жизнь на льдине, вместе с ластоногими.
Получить лучшее из обоих миров, и не потерять темп позволяют некоторые экзотические решения, наподобие Perfect и Vapor. Однако, они в большей степени отвечают на вопрос «Как?» вместо того, чтоб предложить какое-нибудь удовлетворительное минимальное решение. С другой стороны, как правило, исходные требования мобильной команды довольно умерены и стереотипны от одного приложения к другому. Обычно требуется поддержка и управления такими сущностями как аккаунт пользователя, профиль, продукт и изображения.
Чтоб решить указанные проблемы был разработан, и обкатан на AWS проект REST API сервера, который выкладывается в общий доступ. Функциональность не очень большая, но достаточная для того, чтоб разработчики не владеющие глубоко навыками серверной разработки могли собрать и выложить готовое решение в виде Docker контейнера, а при желании, развить проект под свои нужды.
В настоящее время, собранный сервер, решает следующие задачи:
АККАУНТ
Создание аккаунта пользователя по email / password
Basic авторизация пользователя по email / password и создание bearer токена.
Передача bearer в заголовке ответа регистрации / авторизации.
Независимое обновление логина и пароля.
Восстановление пароля.
Email с подтверждением изменения электронного адреса.
Email с подтверждением сброса пароля.
Регистрация и авторизация через Apple.
Регистрация и авторизация через Google.
Мягкое удаление аккаунта.
Восстановление аккаунта в течение определенного срока (30 дней).
ПРОФИЛЬ:
Получение профиля пользователя.
Обновление профиля пользователя.
Обновление аватара текущего пользователя
Получение аватара текущего пользователя.
Получение аватара произвольного пользователя по ID аватара.
Поиск пользователя с учетом уровня доступа.
Изменение роли произвольного пользователя с учетом роли текущего.
Получение профиля пользователя по его ID
ПРОДУКТЫ:
Создание продукта (название, вес)
Получение списка товаров.
Обновление продукта по ID товара.
Удаление продукта.
ИЗОБРАЖЕНИЯ:
Загрузка произвольных изображение на сервер.
Получение произвольных изображений на клиенте.
НОТИФИКАЦИИ:
Отправка Пуш-нотификаций, с передачей title, subtitle, body, payload, звука и badge.
Получение списка ранее созданных нотификаций.
Отметка нотификации как прочитанной.
ИНФРАСТРУКТУРА:
Интеграция платежной системы Stripe
Ограничение использования API по X-Token передаваемого в заголовках запросов.
Обслуживание базы, в процессе авторизации.
Дата создания и изменения каждой записи.
Схема показывает отношение между сущностями базы данных (Postgresql)

Профили пользователя — корневой элемент бинес-логики. К нему в той или иной степени подвязаны все процессы, связанные со стартом мобильного приложения и онбордингом пользователя. Однако, на схеме видно, что есть существенное различие в том, относятся ли действия пользователя к сфере авторизации, или основной работы приложения. Такое разделение предназначено для обеспечения безопасности данных — после прохождения необходимых процедур верификации — пользователь работает со своим профилем и никак не соприкасается с кредами.
Сервер позволяет подключатся одному и тому же пользователю с разных устройств одновременно. В качестве тестового механизма была реализована отправка пуш-уведомлений активной клиентской сессии. Каждое уведомление сохраняется в базе данных, и может быть просмотрено пользователем повторно, как внутренне сообщение в приложении. Таким же образом можно просмотреть список загруженных на сервер изображений.
Одновременно с разработкой сервера, велась разработка и базового iOS приложения, с которому предъявлялись требование присущие Apple платформе. В частности, считается признаком хорошего тона предоставлять пользователю возможность авторизироваться через социальные сети. Но Apple требует, что если присутствует провайдер социальной сети, то, в обязательном порядке, должна быть подключена и Apple авторизация. Но, конечно, нельзя сбрасывать со счетов желание пользователей зарегистрироваться через свой личный email.
Организация работы по восстановлению / изменению пароля сделана через отправку пользователя диплинков — пользователь получает сообщение, в котором присутствует одна из следующих команд:
Восстановление аккаунта
Сброс пароля
Подтверждение
В конфигурации сервера присутствует настройка по отправке сообщений через SMTP сервер. На этапе тестирования таким сервером выступал GMail, но разработчик вправе изменить эту конфигурацию на параметры почтового сервера своей компании.
Таким образом, полученное сообщение содержит необходимые команды, которые запускают мобильное приложение и переадресовывают пользователя на нужные экраны, управления аккаунтом.
Реализация.
Именование.
Проект назван Hermes. Гермес, он же Меркурий — первая от Солнца планета. Как и стриж (Swift) Гермес символизирует скорость. Гермес — покровитель торговли и гибкости. А кроме того, является еще и проводником между подземным и небесным миром. В общем, аллюзий много. Это название часто используется в именованиях и конфигурациях сервера.
Структура проекта.
В основном, для серверной разработки, XCode не применяется. Структура проекта больше напоминает структуру проектов на языках Go, Rust, C++. Частично это обусловлено тем, что в этом случае удобно использовать не полноценные IDE, а редакторы кода, такие как VSCode, Sublime или Atom. Соответственно, структура проекта определяется физической структурой папок — чтоб начать работать с проектом нужно не запустить (открыть) определенный проектный файл, а открыть в редакторе корневую папку. В нашем случае каталог «hermes».
Корневая папка содержи каталоги и файлы:
Public — в основном, пустая папка. Но в нее записываются скрытые файлы гита.
Sources — содержит весь исходный код проекта
Tests — тесты, для автоматического тестирования.
docker-compose.yml — сценарий установки и запуска докер контейнера.
Dockerfile — сценарий сборки докер образа.
Package.resolved — автоматически формируемый файл зависимостей.
Package.swift — файл конфигурации и импорта зависимостей.
AuthKey.p8 — этот файл отсутствует в GitHub репозитории. Его следует создать на портале Apple и разметить в корневом каталоге.
В отличие от XCode — менять менять алфавитное расположение файлов недопустимо.
Папка Sources имеет два каталога:
App — исходный код проекта.
Run — файл main.swift — для запуска проекта средствами редактора — файл main.swift должен быть выбран. Соответственно, он всегда должен оставаться в шаговой доступности.
В папке App расположены основные структурные единицы проекта:
Classes — необходимая обвязка ООП бизнес-логики.
Controllers — классы, непосредственно реализующие REST API.
Extensions — Swift расширения для классов Date и String.
Migrations — файл деклараций, для создания таблиц реляционной базы данных (приведенных на схеме выше).
Models — модели объектов данных.
Models-ext — Swift расширения для моделей.
Конфигурации
Dockerfile
Dockerfile указывает сценарий сборки Linux образа, для размещения его в виде контейнера Docker. В нем присутствует указание на версию Swift который будет использован для сборки, версию ядра Linux, а так же производится конфигурирование портов сервера на локальном хосте. При исполнении файла AuthKey.p8размещается в локальной папке бинарного файла сервера.
# ================================
# Build image
# ================================
FROM swift:5.7-jammy as build
# Install OS updates and, if needed, sqlite3
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y\
&& rm -rf /var/lib/apt/lists/*
# Set up a build area
WORKDIR /build
# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve
# Copy entire repo into container
COPY . .
# Build everything, with optimizations
RUN swift build -c release --static-swift-stdlib
# Switch to the staging area
WORKDIR /staging
# Copy main executable to staging area
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./
# Copy resources bundled by SPM to staging area
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
# Copy any resources from the public directory and views directory if the directories exist
# Ensure that by default, neither the directory nor any of its contents are writable.
RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true
# ================================
# Run image
# ================================
FROM ubuntu:jammy
# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get -q install -y \
ca-certificates \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
# libcurl4 \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
&& rm -r /var/lib/apt/lists/*
# Create a vapor user and group with /app as its home directory
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor
# Switch to the new home directory
WORKDIR /app
# Create Storage
RUN mkdir -p /app/storage/avatars
RUN mkdir -p /app/storage/images
RUN chmod a+w /app/storage/avatars
RUN chmod a+w /app/storage/images
# Copy APNS to container
COPY ./AuthKey.p8 /app
# Copy built executable and any staged resources from builder
COPY --from=build --chown=vapor:vapor /staging /app
# Ensure all further commands run as the vapor user
USER vapor:vapor
# Let Docker bind to port 8080
EXPOSE 8080
# Start the Vapor service when the image is run, default to listening on 8080 in production environment
ENTRYPOINT ["./Run"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
Docker-compose
version: '3.7'
x-shared_environment: &shared_environment
LOG_LEVEL: ${LOG_LEVEL:-debug}
DATABASE_HOST: db
DATABASE_NAME: ${POSTGRES_DB:-hermes_database}
DATABASE_USERNAME: ${POSTGRES_USER:-hermes_username}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-hermes_password}
STORAGE_AVATARS: storage/avatars
STORAGE_IMAGES: storage/images
SMTP_HOST: smtp.gmail.com
SMTP_PORT: 465
SMTP_AUTH:
SMTP_PASS:
services:
db:
image: postgres:14-alpine
volumes:
- db_data:/var/lib/postgresql/data/
environment:
POSTGRES_USER: ${POSTGRES_USER:-hermes_username}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hermes_password}
POSTGRES_DB: ${POSTGRES_DB:-hermes_database}
ports:
- "5432:5432"
networks:
- hermes
app:
image: hermes:latest
build:
context: .
environment:
<<: *shared_environment
depends_on:
- db
ports:
- "8080:8080"
# user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user.
command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
volumes:
- ../storage/avatars:/app/storage/avatars
- ../storage/images:/app/storage/images
networks:
- hermes
migrate:
image: hermes:latest
build:
context: .
environment:
<<: *shared_environment
depends_on:
- db
command: ["migrate", "--yes"]
deploy:
replicas: 0
revert:
image: hermes:latest
build:
context: .
environment:
<<: *shared_environment
depends_on:
- db
command: ["migrate", "--revert", "--yes"]
deploy:
replicas: 0
# mosquitto:
# image: eclipse-mosquitto:latest
# ports:
# - 1883:1883
# - 8883:8883
# - 9001:9001
# volumes:
# - ../storage/mosquitto/data:/mosquitto/data
# - ../storage/mosquitto/log:/mosquitto/log
# networks:
# - hermes
mqtt:
image: demonsoft/hermes_mqtt:v1
ports:
- 1883:1883
- 8883:8883
- 9001:9001
volumes:
- ../storage/mosquitto/data:/mosquitto/data
- ../storage/mosquitto/log:/mosquitto/log
networks:
- hermes
networks:
hermes:
name: hermes
driver: bridge
volumes:
storage:
db_data:
Содержит конфигурацию старта и настройки контейнера, содержащий ранее скомпилированный образ. Здесь дается указание на развертывания сервера базы данных, создание необходимых таблиц, портов и паролей. В секции volumes указываются пути е файловому хранилищу для размещения файлов изображений и аватаров пользователя. Кроме того, поскольку мобильное приложение поддерживает част между пользователями, дополнительно запускается сервис MQTT. Однако, он не является необходимым компонентом, и может быть исключен (закомментирован) в этом файле.
Обратите внимание на секцию x-shared_environment. В ней заданы очень важные параметры для старта сервера:
Параметры с префиксом DATABASE_ — это параметры, с которыми будет произведена первичная конфигурация базы данных. Именно эти параметры необходимо будет использовать чтоб получить доступ как изнутри сервера (hermes), так и извне при помощи инструментов работы с базой данных, к примеру, DBeaver.
Параметры с префиксом SMTP_ — это параметры почтового сервера, при помощи которого пользователю будут отправляться сообщения. Если нагрузка будет небольшой — можно использовать GMail для этих целей. Но в конфигурацию нужно будет прописать email с которого будут уходить почтовые сообщения и пароль от почтового ящика (параметры SMTP_AUTH и SMTP_PASS). SMTP_HOST и SMTP_PORT для GMail можно оставить как есть.
import Fluent
import FluentPostgresDriver
import Vapor
import Smtp
import APNS
import JWTKit
// configures your application
public func configure(_ app: Application) throws {
// uncomment to serve files from /Public folder
// app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
app.databases.use(.postgres(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? PostgresConfiguration.ianaPortNumber,
username: Environment.get("DATABASE_USERNAME") ?? "hermes_username",
password: Environment.get("DATABASE_PASSWORD") ?? "hermes_password",
database: Environment.get("DATABASE_NAME") ?? "hermes_database"
), as: .psql)
app.migrations.add(Profile.Migration())
app.migrations.add(User.Migration())
app.migrations.add(Product.Migration())
app.migrations.add(UserToken.Migration())
app.migrations.add(Confirmation.Migration())
app.migrations.add(Image.Migration())
app.migrations.add(PushToken.Migration())
app.migrations.add(Push.Migration())
try app.autoMigrate().wait()
// SMTP
let smtp_host = Environment.get("SMTP_HOST") ?? "smtp.gmail.com"
let smtp_port = Int(Environment.get("SMTP_PORT") ?? "") ?? 465
let smtp_auth = Environment.get("SMTP_AUTH") ?? ""
let smtp_pass = Environment.get("SMTP_PASS") ?? ""
app.smtp.configuration.hostname = smtp_host
app.smtp.configuration.port = smtp_port
app.smtp.configuration.secure = .ssl
app.smtp.configuration.signInMethod = .credentials(username: smtp_auth, password: smtp_pass)
// ONLY FOR PRODUCT - It restricts using API
// app.middleware.use(CerberusMiddleware())
// register routes
try routes(app)
// THIS IS TEMPORARY CODE ---------------------------
// let fileMngr = FileManager.default
// let current = "\(fileMngr.currentDirectoryPath)"
// print("[ PATH: ] \(current)")
// let files = try fileMngr.contentsOfDirectory(atPath:current)
// print("[ FILES: ] \(files)")
// THIS IS TEMPORARY CODE ---------------------------
// Configure APNS using JWT authentication.
app.apns.configuration = try .init(
authenticationMethod: .jwt(
key: .private(filePath: Constants.apnCertPath),
keyIdentifier: JWKIdentifier(string: Constants.keyIdentifier),
teamIdentifier: Constants.teamIdentifier
),
topic: Constants.apnTopic,
environment: .sandbox
)
//app.lifecycle.use(Storage())
}
В файле configure.swift заданы те же самые параметры, которые содержатся в Docker-compose. Разумеется, в идеале было бы лучше вынести их в отдельный файл, предварительно, загружаемый при старте бинарного файла сервера, и исключенный из Git репозитория.
//
// File.swift
//
//
// Created by Dmitriy Soloshenko on 13.03.2023.
//
import Foundation
import RoutingKit
struct Constants {
static let lifetime = 30 // Lifetime of auth token. 30 days
static let lifetimePass = Date.oneHour // Lifetime of change password. 1 hour
static let restoreAcc = 30 // Duration that user can restore account after delete. 30 days
static let xTokenHeader = "X-Token"
static let xTokenValue = ""
static let bcryptSalt = ""
static let xBearer = "Bearer"
static let avatarStorage = "storage/avatars/"
static let imageStorage = "storage/images/"
static let avatarExtension = ".jpg"
static let imageExtension = ".jpg"
static let minimalPassword = 8
static let iosAppName = "hermesApp"
// MARK: - NETWORK
static let restRootGroup = PathComponent("api")
static let restVersion = PathComponent("v.1")
static let backEmail = ""
static let serviceName = "Hermes Core Server"
// MARK: - APN
static let apnCertPath = "AuthKey.p8"
static let keyIdentifier = ""
static let teamIdentifier = ""
static let apnTopic = "com.."
// MARK: - Deep links
static let restoreAccDL = "://restore-acc/"
static let resetPassDL = "://reset-password/"
static let confirmChangeDL = "://confirm-change/"
// MARK: - STRIPE
static let stipePublicKey =
"pk_test_"
static let stipePrivateKey =
"sk_test_"
}
Структура, в которой содержатся константные значения конфигурации. При промышленной эксплуатации сервера их так же необходимо вынести в отдельный файл, недоступный в Git хранилище.
xToken — применяется для ограничения пользователей на использование API. В middleware слое сервера происходит проверка на наличие значения, и если значение не совпадает, то клиентское приложение получает отказ в обслуживании.
xTokenHeader — имя поля для размещения токена.
xTokenValue — значение токена которое знает легальный клиент, и передает в заголовке REST запроса.
bcryptSalt — строковое значение используемое в качестве соли шифрования токенов авторизации и отправки Push уведомлений.
iosAppName — схема приложения, для использование ее при создании DeepLinks.
backEmail — email — адрес от имени которого будут отправляться электронные письма.
Ключи для Push Notofocation Server
serviceName — имя сервиса, который указывается в заголовках писем.
apnCertPath — имя файла, приватного ключа, который используется для отправки Push уведомлений.
keyIdentifier — идентификатор, который расположен на портале Apple.
teamIdentifier — идентифкатор компании, которой принадлежит приложение, получаемое Push уведомления.
apnTopic — обязательные идентификатор, как правило, совпадающий с Bundle id iOS приложения.
Ключи платежной системы Stripe которые можно получить на официальном портале.
stipePublicKey — пубичный ключ.
stipePrivateKey — приватный ключ.
Остальные ключи — самоочевидны, и не требуют комментариев. Можно оставить как есть.
Модели
По большому счету модели делятся на два типа — внутренняя реализация бизнес-объектов, и Data Transfer Models (DTO) — модели при помощи которых происходит обмен данными в Rest запросах. Следует обратить внимание на то, что эти модели унаследованы от протоколов Model и Content и имеют большое количество атрибутов при определении своих полей. DTO размещается в виде структуры Public внутри бизнес-модели.
Рассмотрим модели User и Profile.
import Fluent
import Vapor
final class User: Model, Content {
static var schema: String = "users"
@ID var id: UUID?
@Field(key: "email") var email: String
@Field(key: "password") var password: String
@Field(key: "social_id") var socialId: String?
@Parent(key: "profile_id") var profile: Profile
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
@Timestamp(key: "deleted_at", on: .delete) var deletedAt: Date?
init() {}
init(id: UUID? = nil,
email: String,
password: String,
socialId: String? = nil,
profile: Profile.IDValue) {
self.id = id
self.email = email
self.password = password
self.socialId = socialId
self.$profile.id = profile
}
}
import Fluent
import Vapor
final class Profile: Model, Content {
enum Gender: String, Codable {
case unknown, male, female
}
static var schema: String = "profiles"
@ID(key: .id) var id: UUID?
@Field(key: "first_name") var first_name: String
@Field(key: "last_name") var last_name: String
@Field(key: "nickname") var nickname: String
@Enum(key: "gender") var gender: Gender
@Field(key: "age") var age: Int?
@Enum(key: "role") var role: Role
@Field(key: "avatar") var avatar: UUID?
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
@Timestamp(key: "deleted_at", on: .delete) var deletedAt: Date?
init() {}
init(id: UUID = UUID(),
first_name: String = "",
last_name: String = "",
nickname: String = "",
gender: Gender = .unknown,
age: Int? = nil,
role: Role = .user,
avatar: UUID? = nil) {
self.id = id
self.first_name = first_name
self.last_name = last_name
self.nickname = nickname
self.gender = gender
self.age = age
self.role = role
self.avatar = avatar
}
struct Public: Content, Encodable, Hashable {
let id: UUID?
let first_name: String
let last_name: String
let nickname: String
let gender: Gender
let age: Int?
let role: Role?
let avatar: UUID?
}
}
После определения класса следует указание на то, из какой таблицы базы данных будут извлечены записи. Эти же названия фигурируют в файлах CreateUsers.swift и CreateProfiles.swift — в этих файлах определены правила миграции (создания таблиц баз данных). Причем, миграции являются Swift расширениями моделей данных.
import Fluent
extension Profile {
struct Migration: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("profiles")
.id()
.field("first_name", .string, .required)
.field("last_name", .string, .required)
.field("nickname", .string, .required)
.field("gender", .string, .required)
.field("age", .int)
.field("role", .string, .required)
.field("avatar", .uuid)
.field("created_at", .datetime)
.field("updated_at", .datetime)
.field("deleted_at", .datetime)
.create()
}
func revert(on database: Database) async throws {
try await database.schema("profiles").delete()
}
}
}
import Fluent
extension User {
struct Migration: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("users")
.id()
.field("email", .string, .required)
.field("password", .string, .required)
.field("social_id", .string)
.field("profile_id", .uuid, .required, .references("profiles", "id"))
.field("created_at", .datetime)
.field("updated_at", .datetime)
.field("deleted_at", .datetime)
.unique(on: "email")
.create()
}
func revert(on database: Database) async throws {
try await database.schema("users").delete()
}
}
}
Атрибут @ID определяет, что поле является первичным ключом таблицы базы данных.
Аттрибут @Field преобразует название поля из snake-case стиля, в camel-case.
Аттрибут @Parent связывает поле profile_id с объектом Profile по его первичному ключу.
Аттрибут @Timestamp определяет тип поля, а так же правила, для его автоматического изменения (при создании, удалении и обновлении).
В модели Profile есть пару интересных отличий,
Аттрибут @Enum позволяет задать значение из перечисления.
Собственно, DTO объект определенный Public структурой. Именно эти поля будут передаваться при ответах сервера.
REST API
Все REST запросы к серверу формируются вокруг CRUD концепции (Create, Read, Update, Delete), конечно, если в каких-то из этих команд есть смысл. Семейства запросов объединены в контроллеры: ProductControllerотвечает за бизнес логику работы моделью продуктов.
Каждый класс контроллера унаследован от RouteCollection, и должен содержать метод boot, в котором определены все команды семейства.
import Fluent
import Vapor
struct ProductController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let products = routes.root.grouped("products")
let protected = products.grouped(UserToken.authenticator())
protected.get(use: index)
protected.post(use: create)
protected.put(":id", use: update)
protected.delete(":id", use: delete)
}
func index(req: Request) async throws -> Response {
try await Profile.auth(req)
let products = try await Product.query(on: req.db).all()
return Response.success(products)
}
func create(req: Request) async throws -> Response {
try await Profile.auth(req)
guard let product = try? req.content.decode(Product.self) else {
return Response.fail(Mistakes.iAmTeapot)
}
try await product.save(on: req.db)
return Response.success(product)
}
func update(req: Request) async throws -> Response {
try await Profile.auth(req)
guard let product = try await Product.find(req.parameters.get("id"), on: req.db) else {
return Response.fail(Mistakes.idNotFound)
}
let updated = try req.content.decode(Product.self)
product.name = updated.name
product.weight = updated.weight
product.price = updated.price
product.currency = product.currency
try await product.save(on: req.db)
return Response.success(product)
}
func delete(req: Request) async throws -> Response {
try await Profile.auth(req)
guard let product = try await Product.find(req.parameters.get("id"), on: req.db) else {
return Response.fail(Mistakes.productCannotDelete)
}
try await product.delete(on: req.db)
return Response.success("OK")
}
}
В примере показано создание GET, POST, PUT и DELETE команд для запросов в коллекции products. Все четыре команды защищены авторизационным токеном. Т. е. без передачи валидного токена клиентское приложение будет получать отлуп.
Middlewares
В коде сервера определен вызов двух middlewares — Cerberus и Roles.
Cerberus предназначен для проверки xToken для всех вызовов сервера. Его активация проихводится в файле конфигурации. Активация закомментирована, поскольку на стадии разработки, проверка xToken на локальной платформе не требуется. Перед публикацией на рабочем сервере активацию следует раскомментировать, или как вариант, во все тестовые запросы добавлять валидное значение xToken.
import Vapor
import Fluent
final class CerberusMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
guard request.headers.contains(name: Constants.xTokenHeader),
let value = request.headers[Constants.xTokenHeader].first,
value == Constants.xTokenValue
else { throw self.exception(request) }
return try await next.respond(to: request)
}
private func exception(_ request: Request) -> Abort {
let errorResponse = Response(status: .forbidden)
let message = "This API isn't accessible for public using."
errorResponse.body = .init(string: message)
return Abort(.badRequest, reason: message)
}
}
import Vapor
import Fluent
final class RolesMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
let profile = try await request.me()
guard profile.role.available(.manager) else {
throw Abort (.forbidden)
}
return try await next.respond(to: request)
}
}
Roles — middleware предранзачен для проверки того, с какой ролью пользователь входит на сервер. В перечислении Roles определено пять ролей: owner, admin, manager, user, lowest. Операция по получению сведения о пользователях доступны только для пользователей, которые имею роль manager и выше.
Storage
При старте приложение происходит проверка на наличие каталогов для загрузки изобржений и аватаров пользователей. Если коревые директории отсутствуют — они создаются. Имена берутся из конфигурации окружения.
import Foundation
import Vapor
class Storage: LifecycleHandler {
// Called before application boots.
func willBoot(_ app: Application) throws {
do {
try createIfNotExist(app, Environment.get("STORAGE_AVATARS") ?? Constants.avatarStorage)
try createIfNotExist(app, Environment.get("STORAGE_IMAGES") ?? Constants.imageStorage)
}
catch let e {
print("FILE SYSTEM: \(e.localizedDescription)")
}
}
private func createIfNotExist(_ app: Application, _ path: String) throws {
let fullPath = app.directory.workingDirectory + path
try FileManager.default.createDirectory(atPath: fullPath, withIntermediateDirectories: true)
}
}
APNS
Серер может уведомить пользователя клиентского приложения о каком-либо событии путем отправки Apple Push Notification. Все устройства пользователя получают уведомление, которое содержит титутл, самтитул, основной текст и звуковой сигнал. Кроме того, на значке приложение добавляется индекс о не прочитанном сообщении. Сервер ведет учет отправленных сообщений, и отслеживает, было ли сообщение прочитано на клиенте внутри приложения.
import Fluent
import Vapor
import APNS
class PushSender {
func send(req: Request, tokens:[PushToken], title: String, subtitle: String?, body: String?) async throws {
for token in tokens {
let unread = try await token.countUnread(req)
let alert = APNSwiftAlert(title: title, subtitle: subtitle, body: body)
let aps = APNSwiftPayload(alert: alert, badge: unread + 1, sound: .normal("cow.wav"))
let _ = req.apns.send(aps, to: token.token)
guard let id = token.id else { throw Abort(.internalServerError) }
let push = Push(pushToken: id, title: title, subtitle: subtitle, body: body)
try await push.save(on: req.db)
}
}
}
Заключение.
Разумеется, приведенных исходный код не может покрыть весь необходимый для любого REST API сервиса функционал. Но он может стать стартовым этапом для быстрого развертывания Ваших решений, поскольку имеет отлаженные методы для типовых задач сервера поддержки мобильного приложения.
Исходный код доступен в GitHub.
Обсудить можно в телеграм чате.
Habrahabr.ru прочитано 5979 раз