Подключение из iOS

Руководство по работе с API из Swift: модели, расчёт цены, дополненная реальность.

Транспорт

Сервер отдаёт валидный сертификат Let's Encrypt, поэтому App Transport Security доволен и правки в Info.plist не нужны. Никаких исключений вроде NSAllowsArbitraryLoads добавлять не требуется — а если добавите, App Store попросит объяснений.

enum API {
    static let base = URL(string: "https://api.desperatemeasure.tech")!

    static func url(_ path: String) -> URL {
        base.appendingPathComponent(path)
    }

    /// Пути к медиа приходят относительными: /media/elio/1-1200.webp
    static func asset(_ relative: String) -> URL {
        URL(string: relative, relativeTo: base)!.absoluteURL
    }
}

Модели

Схему можно не писать руками — она есть в OpenAPI:

curl https://api.desperatemeasure.tech/openapi.json -o openapi.json
swift-openapi-generator generate openapi.json --mode types

Если генератор подключать не хочется, вот эквивалентные структуры. Имена полей в API в snake_case, поэтому достаточно поставить .convertFromSnakeCase и обойтись без CodingKeys.

struct ImageSet: Codable, Hashable {
    let thumb: String
    let detail: String
    let full: String
}

struct Dimensions: Codable, Hashable {
    let shape: String?
    let diameterMm: Int?
    let diameterExtendedMm: Int?
    let widthMm: Int?
    let widthExtendedMm: Int?
    let depthMm: Int?
    let heightMm: Int?
    let heightOptionsMm: [Int]
    let parsed: Bool
}

struct BaseOption: Codable, Hashable, Identifiable {
    let id: String
    let label: String
    let price: Int
    let dimensions: Dimensions
    /// [идентификатор группы: [идентификатор варианта: надбавка]]
    let priceMatrix: [String: [String: Int]]
    /// [идентификатор опции: надбавка]
    let optionPrices: [String: Int]
}

struct Choice: Codable, Hashable, Identifiable {
    let id: String
    let label: String
    let swatches: [String]
}

struct ChoiceGroup: Codable, Hashable, Identifiable {
    let id: String
    let title: String
    let options: [Choice]
}

struct BaseGroup: Codable, Hashable {
    let id: String
    let title: String
    let options: [BaseOption]
}

struct Extra: Codable, Hashable, Identifiable {
    let id: String
    let label: String
    let price: Int
}

struct Finish: Codable, Hashable, Identifiable {
    let id: String
    let label: String
    let image: String?
}

struct FinishSection: Codable, Hashable {
    let part: String
    let category: String
    let items: [Finish]
}

struct Product: Codable, Hashable, Identifiable {
    var id: String { slug }

    let slug: String
    let title: String
    let category: String
    let isNew: Bool
    let description: String

    let priceFrom: Int?
    let priceTo: Int?
    let priceOnRequest: Bool
    let priceWarnings: [String]

    let images: [ImageSet]
    let hasAr: Bool
    let modelUrl: String?

    let baseGroup: BaseGroup
    let deltaGroups: [ChoiceGroup]
    let extras: [Extra]
    let finishes: [FinishSection]
}

struct ProductSummary: Codable, Hashable, Identifiable {
    var id: String { slug }

    let slug: String
    let title: String
    let category: String
    let isNew: Bool
    let priceFrom: Int?
    let priceOnRequest: Bool
    let hasAr: Bool
    let image: ImageSet?
}

struct Category: Codable, Hashable, Identifiable {
    let id: String
    let title: String
    let count: Int
}

Клиент

actor CatalogClient {
    private let decoder: JSONDecoder = {
        let d = JSONDecoder()
        d.keyDecodingStrategy = .convertFromSnakeCase
        return d
    }()

    private func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
        var components = URLComponents(url: API.url(path), resolvingAgainstBaseURL: false)!
        if !query.isEmpty { components.queryItems = query }

        let (data, response) = try await URLSession.shared.data(from: components.url!)
        guard let http = response as? HTTPURLResponse else { throw CatalogError.badResponse }
        guard http.statusCode == 200 else {
            throw CatalogError.http(status: http.statusCode, body: String(decoding: data, as: UTF8.self))
        }
        return try decoder.decode(T.self, from: data)
    }

    func categories() async throws -> [Category] {
        try await get("v1/categories")
    }

    func products(category: String? = nil, hasAR: Bool? = nil) async throws -> [ProductSummary] {
        var query: [URLQueryItem] = []
        if let category { query.append(.init(name: "category", value: category)) }
        if let hasAR { query.append(.init(name: "has_ar", value: hasAR ? "true" : "false")) }
        return try await get("v1/products", query: query)
    }

    func product(_ slug: String) async throws -> Product {
        try await get("v1/products/\(slug)")
    }
}

enum CatalogError: Error {
    case badResponse
    case http(status: Int, body: String)
}

Расчёт цены

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

struct Configuration {
    /// Идентификатор выбранного варианта базовой группы (размер или ткань).
    var baseOptionID: String
    /// [идентификатор группы: идентификатор выбранного варианта]
    var selections: [String: String]
    /// Отмеченные опции
    var extras: Set<String>
}

extension Product {
    /// Конфигурация по умолчанию: первый вариант в каждой группе, опции сняты.
    var defaultConfiguration: Configuration {
        Configuration(
            baseOptionID: baseGroup.options[0].id,
            selections: Dictionary(uniqueKeysWithValues: deltaGroups.compactMap { group in
                group.options.first.map { (group.id, $0.id) }
            }),
            extras: []
        )
    }

    func price(for config: Configuration) -> Int? {
        guard let base = baseGroup.options.first(where: { $0.id == config.baseOptionID }) else {
            return nil
        }
        let deltas = config.selections.reduce(0) { sum, pair in
            sum + (base.priceMatrix[pair.key]?[pair.value] ?? 0)
        }
        let options = config.extras.reduce(0) { $0 + (base.optionPrices[$1] ?? 0) }
        return base.price + deltas + options
    }
}

Никаких ветвлений по типу товара здесь нет намеренно: на сайте-источнике цена считается тремя разными способами, но всё это разобрано на стороне сервера. Подробности — в PARSER.md.

Форматирование под рубли:

extension Int {
    var asRubles: String {
        let f = NumberFormatter()
        f.numberStyle = .currency
        f.currencyCode = "RUB"
        f.maximumFractionDigits = 0
        f.locale = Locale(identifier: "ru_RU")
        return f.string(from: NSNumber(value: self)) ?? "\(self) ₽"
    }
}

Экран товара

Набор групп выбора у разных товаров различается: у стола это размер, материал и опции, у стула — только ткань, у восьми изделий добавляется фасад. Поэтому экран строится циклом по тому, что пришло с сервера, а не фиксированной вёрсткой.

struct ConfiguratorView: View {
    let product: Product
    @State private var config: Configuration

    init(product: Product) {
        self.product = product
        _config = State(initialValue: product.defaultConfiguration)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 24) {
            // базовая группа — размер или ткань
            picker(title: product.baseGroup.title,
                   options: product.baseGroup.options.map { ($0.id, $0.label) },
                   selection: $config.baseOptionID)

            // группы надбавок
            ForEach(product.deltaGroups) { group in
                picker(title: group.title,
                       options: group.options.map { ($0.id, $0.label) },
                       selection: Binding(
                           get: { config.selections[group.id] ?? "" },
                           set: { config.selections[group.id] = $0 }))
            }

            // опции-галочки
            ForEach(product.extras) { extra in
                Toggle(extra.label, isOn: Binding(
                    get: { config.extras.contains(extra.id) },
                    set: { on in
                        if on { config.extras.insert(extra.id) }
                        else { config.extras.remove(extra.id) }
                    }))
            }

            if let total = product.price(for: config) {
                Text(total.asRubles).font(.title2.bold())
            } else if product.priceOnRequest {
                Text("Цена по запросу").foregroundStyle(.secondary)
            }
        }
    }

    @ViewBuilder
    private func picker(title: String, options: [(String, String)], selection: Binding<String>) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title).font(.subheadline).foregroundStyle(.secondary)
            Picker(title, selection: selection) {
                ForEach(options, id: \.0) { Text($0.1).tag($0.0) }
            }
            .pickerStyle(.segmented)
        }
    }
}

Дополненная реальность

Готовая модель

Кнопку AR показывайте только там, где hasAr равно true. Пока каталог models/ на сервере пуст, это не сработает ни на одном товаре — см. OPERATIONS.md.

import QuickLook

struct ARQuickLook: UIViewControllerRepresentable {
    let modelURL: URL

    func makeUIViewController(context: Context) -> QLPreviewController {
        let controller = QLPreviewController()
        controller.dataSource = context.coordinator
        return controller
    }

    func updateUIViewController(_ controller: QLPreviewController, context: Context) {}

    func makeCoordinator() -> Coordinator { Coordinator(url: modelURL) }

    final class Coordinator: NSObject, QLPreviewControllerDataSource {
        let url: URL
        init(url: URL) { self.url = url }

        func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
        func previewController(_ controller: QLPreviewController,
                               previewItemAt index: Int) -> QLPreviewItem {
            url as QLPreviewItem
        }
    }
}

Quick Look не открывает удалённые ссылки напрямую — файл нужно сначала сохранить локально:

func downloadModel(_ product: Product) async throws -> URL {
    guard let path = product.modelUrl else { throw CatalogError.badResponse }
    let remote = API.asset(path)
    let destination = FileManager.default.temporaryDirectory
        .appendingPathComponent("\(product.slug).usdz")

    if FileManager.default.fileExists(atPath: destination.path) { return destination }

    let (temp, _) = try await URLSession.shared.download(from: remote)
    try? FileManager.default.removeItem(at: destination)
    try FileManager.default.moveItem(at: temp, to: destination)
    return destination
}

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

Процедурная геометрия

Для круглых столов модель можно не готовить вовсе: диаметр и высота известны точно, а текстуру берём из образцов отделок. Так каталог получает AR без единого файла USDZ.

import RealityKit

func makeTable(for option: BaseOption, texture: TextureResource?) -> ModelEntity? {
    let d = option.dimensions
    guard d.parsed, d.shape == "round",
          let diameterMm = d.diameterMm, let heightMm = d.heightMm else { return nil }

    let radius = Float(diameterMm) / 2000   // миллиметры в метры
    let height = Float(heightMm) / 1000
    let topThickness: Float = 0.03

    var material = PhysicallyBasedMaterial()
    if let texture {
        material.baseColor = .init(texture: .init(texture))
    }
    material.roughness = 0.35
    material.metallic = 0.0

    let top = ModelEntity(
        mesh: .generateCylinder(height: topThickness, radius: radius),
        materials: [material]
    )
    top.position.y = height - topThickness / 2

    let base = ModelEntity(
        mesh: .generateCylinder(height: height - topThickness, radius: radius * 0.18),
        materials: [material]
    )
    base.position.y = (height - topThickness) / 2

    let table = ModelEntity()
    table.addChild(top)
    table.addChild(base)
    return table
}

Проверяйте dimensions.parsed перед построением: у четырёх товаров без конфигуратора и двух барных стульев размеров в источнике нет.

Для прямоугольных изделий берите widthMm и depthMm, а у раздвижных столов widthExtendedMm и diameterExtendedMm дают второе состояние — хороший повод показать трансформацию прямо в комнате.

Изображения

Три размера на каждое фото: thumb (400 px) для списков, detail (1200 px) для карточки, full (2000 px) для полноэкранного просмотра. Формат WebP, который iOS поддерживает начиная с 14-й версии.

AsyncImage(url: API.asset(summary.image?.thumb ?? "")) { image in
    image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
    Color.secondary.opacity(0.1)
}

Изображения отдаются с Cache-Control: immutable на год, поэтому системного кэша URLSession достаточно и своё хранилище заводить не нужно.

Образцы отделок из /media/_finishes/ общие для всех товаров — они скачаются один раз и дальше будут браться из кэша.

Что стоит учесть

Цена по запросу. У четырёх товаров цены нет вовсе. Проверяйте priceOnRequest, иначе в интерфейсе появится «0 ₽».

Одна ошибка в данных. У товара harry размер 2400 мм стоит 35 900 ₽ при соседних 325 000 и 392 000 ₽ — на сайте-источнике потеряна цифра. Значение оставлено как есть; список замечаний лежит в priceWarnings.

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