- PVSM.RU - https://www.pvsm.ru -

Работа с сервером с помощью Alamofire на Swift

Работа с сервером с помощью Alamofire на Swift - 1

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

Содержание

Зачем

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

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

Установка

Воспользуемся CocoaPods т.к. с ним очень легко и быстро работать.

Добавим в Podfile:

pod 'Alamofire'

Для использования Alamofire версии 4+ необходимы следующие требования:

  • iOS 9.0+ / macOS 10.11+ / tvOS 9.0+ / watchOS 2.0+
  • Xcode 8.0+
  • Swift 3.0+
  • CocoaPods 1.1.0+

Так же нам необходимо добавить use_frameworks!.

Так будет выглядеть минимальный Podfile:

platform :ios, '9.0'
use_frameworks!

target 'Networking' do

    pod 'Alamofire'

end

Настройка доступа HTTP

По умолчанию в приложении закрыт доступ к HTTP соединениям, доступны только HTTPS. Но пока еще очень много сайтов не перешли на https.

Мы будем работать с сервером http://jsonplaceholder.typicode.com [30], а он работает по http. Поэтому нам надо открыть доступ для него.

Для тренировки мы откроем доступ для всех сайтов. Открытие для одного сайта в данной статье не буду рассматривать.

Открываем Info.plist и добавляем в него App Transport Security Settings и внутрь этого параметра необходимо добавить Allow Arbitrary Loads, со значением YES.

Выглядеть это должно следующим образом:

![Info.plist](/Users/zdaecqzezdaecq/Downloads/Работа с запросам с помощью Alamofire/info_plist.png)

Или вот Source code, который необходимо добавить:

правой кнопкой мыши на Info.plist -> Open as -> Source code

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

Первый минимальный запрос

Открываем проект.

Не забудьте, что нам нужно открыть Networking.xcworkspace, а не Networking.xcodeproj, который создался после pod install

Открываем файл ViewController.swift и заменяем его код на следующий:

import UIKit
import Alamofire

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        request("http://jsonplaceholder.typicode.com/posts").responseJSON { response in
            print(response)
        }
        print("viewDidLoad ended")
    }
}

Запускайте проект.
В консоли выведится:

viewDidLoad ended
SUCCESS: (
        {
        body = "quia et suscipitnsuscipit recusandae consequuntur expedita et cumnreprehenderit molestiae ut ut quas totamnnostrum rerum est autem sunt rem eveniet architecto";
        id = 1;
        title = "sunt aut facere repellat provident occaecati excepturi optio reprehenderit";
        userId = 1;
    },
    ...

Поздравляю! Вы сделали первый запрос на сервер и получили от него ответ с результатом.

Подробнее о минимуме

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

import Alamofire

Собственно сам метод запроса:

request

Далее первым параметром передается URL, по которому будет производится запрос:

"http://jsonplaceholder.typicode.com/posts"

Метод responseJSON говорит о том, что ответ от сервера нам нужен в JSON формате.

Далее в клоужере мы получаем ответ от сервера и выводим его в консоль:

{ response in
    print(response)
}

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

Методы HTTP

На самом деле мы сделали GET запрос, но нигде этого не указывали. Начиная с Alamofire 4 по умолчанию выполняется GET запрос. Мы может его явно указать, заменив соответствующий код на следующий:

request("http://jsonplaceholder.typicode.com/posts", method: .get)

Как Вы уже поняли в параметре method: передается метод запроса и от него зависит, как мы будем общаться с сервером. Чаще всего мы будем:

  1. получать (GET)
  2. изменять (PUT)
  3. отправлять, создавать (POST)
  4. удалять (DELETE)

данные с сервера.

Подробнее про эти и другие методы HTTP можете почитать на википедии:

  1. Протокол HTTP [31]
  2. Методы HTTP [32]

Alamofire.request

Функция request — глобальная функция, поэтому мы можем ее вызывать через Alamofire.request или просто request.

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

request(URLConvertible, method: HTTPMethod, parameters: Parameters?, encoding: ParameterEncoding, headers: HTTPHeaders?)

Рассмотрим подробнее:

URLConvertible

Первым параметром является путь запросу и он принимает URLConvertible. (Ваш КЭП)

Если мы посмотрим на его реализацию, то увидим, что это протокол с одной функцией:

public protocol URLConvertible {
    func asURL() throws -> URL
}

и он уже реализован для следующих типов данных:

  • String
  • URL
  • URLComponents

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

HTTPMethod

Это enum, со всеми возможными типами запросов:

public enum HTTPMethod: String {
    case options = "OPTIONS"
    case get     = "GET"
    case head    = "HEAD"
    case post    = "POST"
    case put     = "PUT"
    case patch   = "PATCH"
    case delete  = "DELETE"
    case trace   = "TRACE"
    case connect = "CONNECT"
}

Как мы уже выяснили: по умолчанию .get
Тут ничего сложного, идем дальше.

Parameters

Это простой Dictionary:

public typealias Parameters = [String: Any]

Через параметры мы будем передавать данные на сервер (например, для изменения или создания объектов).

ParameterEncoding

Это тоже протокол с одной функцией:

public protocol ParameterEncoding {
    func encode(_ urlRequest: URLRequestConvertible, with parameters: Parameters?) throws -> URLRequest
}

Он необходим для определения в каком виде нам закодировать наши параметры. Разные серверы и запросы требуют определенной кодировки.

Этот протокол реализуют:

  • URLEncoding
  • JSONEncoding
  • PropertyListEncoding

По умолчанию у нас URLEncoding.default.

В основном этот параметр не используется, но иногда бывает нужен, в частности JSONEncoding.default для кодировки в JSON формате и PropertyListEncoding.default в XML.

Я заметил, что Int не отправляется без JSONEncoding.default, но возможно это было в Alamofire 3, а может из-за сервера. Просто имейте это ввиду.

HTTPHeaders

Это также Dictionary, но другой типизации:

public typealias HTTPHeaders = [String: String]

Headers(заголовки) нам будут необходимы в основном для авторизации.

Подробнее про заголовки на википедии:

Заголовки HTTP [33]

DataRequest

На выходе мы получаем объект типа DataRequest — сам запрос. Его мы можем сохранить, передать, как параметр в другую функцию при необходимости, донастроить и отправить. Об этом далее.

Обработка ответа

Ответ от сервера может прийти, как с результатом, так и с ошибкой. Для того, чтобы их различать у ответа есть такие параметры, как statusCode и contentType.

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

Ручная обработка ответа

Если мы не настраивали валидацию, то в

responseJSON.response?.statusCode

у нас будет статус код ответа, а в

responseJSON.result.value

будет результат, если ответ пришел без ошибки, и в

responseJSON.result.error

если с ошибкой.

request("http://jsonplaceholder.typicode.com/posts").responseJSON { responseJSON in

    guard let statusCode = responseJSON.response?.statusCode else { return }
    print("statusCode: ", statusCode)

    if (200..<300).contains(statusCode) {
        let value = responseJSON.result.value
        print("value: ", value ?? "nil")
    } else {
        print("error")
    }
}

Подробнее про коды состояний на википедии:

Коды состояния HTTP [34]

Настройка запроса

Для этого у DataRequest есть 4 метода:

  1. validate(statusCode: _ )
  2. validate(contentType: _ )
  3. validate(клоужер для ручной валидации)
  4. validate()

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

Взглянем на его реализацию:

public func validate() -> Self {
    return validate(statusCode: self.acceptableStatusCodes).validate(contentType: self.acceptableContentTypes)
}

Видим, что он состоит из двух других валидаций:

  1. self.acceptableStatusCodes — возвращает массив статус кодов(Int) из range 200..<300
  2. self.acceptableContentTypes — возвращает массив допустимых хедеров(String)

У DataResponse есть параметр result, который может сказать нам, пришел ответ с ошибкой или с результатом.

Итак, применим валидацию для запроса:

request("http://jsonplaceholder.typicode.com/posts").validate().responseJSON { responseJSON in

    switch responseJSON.result {
    case .success(let value):
        print(value)
    case .failure(let error):
        print(error)
    }
}

Если у нас не будет вылидации запроса (validate()), то result всегда будет равен .success, за исключением ошибки из-за отсутствия интернета.

Можно обрабатывать ответ обоими способами, но я настоятельно рекомендую пользоваться настройкой валидации запроса — будет меньше ошибок!

Обработка результата ответа

Ответ от сервера чаще всего бывает в виде одного объекта или массива объектов.

Если мы посмотрим на тип результата ответа, то увидим тип Any. Чтобы из него что-то достать — нам надо его привести к нужному формату.

В логах мы замечали, что у нас приходит массив Dictionary, поэтому к нему и будем приводить:

request("http://jsonplaceholder.typicode.com/posts").responseJSON { responseJSON in

    switch responseJSON.result {
    case .success(let value):
        print("value", value)

        guard let jsonArray = responseJSON.result.value as? [[String: Any]] else { return }
        print("array: ", jsonArray)
        print("1 object: ", jsonArray[0])
        print("id: ", jsonArray[0]["id"]!)
    case .failure(let error):
        print(error)
    }
}

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

В отдельном файле создадим структуру Post:

struct Post {
    var id: Int
    var title: String
    var body: String
    var userId: Int
}

Так будет выглядеть парсинг в массив объектов:

request("http://jsonplaceholder.typicode.com/posts").responseJSON { responseJSON in

    switch responseJSON.result {
    case .success(let value):

        guard let jsonArray = value as? Array<[String: Any]> else { return }

        var posts: [Post] = []

        for jsonObject in jsonArray {
            guard
                let id = jsonObject["id"] as? Int,
                let title = jsonObject["title"] as? String,
                let body = jsonObject["body"] as? String,
                let userId = jsonObject["userId"] as? Int
            else {
                return
            }
            let post = Post(id: id, title: title, body: body, userId: userId)
            posts.append(post)
        }

        print(posts)

    case .failure(let error):
        print(error)
    }
}

Парсинг объекта внутри запроса выглядит очень плохо + нам придется всегда копировать эти строки для каждого запроса. Чтобы от этого избавиться создадим конструктор init?(json: [String: Any]):

init?(json: [String: Any]) {

    guard
        let id = json["id"] as? Int,
        let title = json["title"] as? String,
        let body = json["body"] as? String,
        let userId = json["userId"] as? Int
    else {
        return nil
    }

    self.id = id
    self.title = title
    self.body = body
    self.userId = userId
}

Он может вернуть nil, если сервер нам что-то не вернул

И тогда метод запроса выглядит на много понятнее и приятнее:

request("http://jsonplaceholder.typicode.com/posts").responseJSON { responseJSON in

    switch responseJSON.result {
    case .success(let value):

        guard let jsonArray = value as? Array<[String: Any]> else { return }
        var posts: [Post] = []

        for jsonObject in jsonArray {
            guard let post = Post(json: jsonObject) else { return }
            posts.append(post)
        }
        print(posts)

    case .failure(let error):
        print(error)
    }
}

Пойдем еще дальше и в Post добавим метод обработки массива:

static func getArray(from jsonArray: Any) -> [Post]? {

    guard let jsonArray = jsonArray as? Array<[String: Any]> else { return nil }
    var posts: [Post] = []

    for jsonObject in jsonArray {
        if let post = Post(json: jsonObject) {
            posts.append(post)
        }
    }
    return posts
}

Тогда метод запроса примет следующий вид:

request("http://jsonplaceholder.typicode.com/posts").responseJSON { responseJSON in

    switch responseJSON.result {
    case .success(let value):
         guard let posts = Post.getArray(from: value) else { return }
         print(posts)

    case .failure(let error):
        print(error)
    }
}

Конечный вариант файла Post.swift:

import Foundation

struct Post {

    var id: Int
    var title: String
    var body: String
    var userId: Int

    init?(json: [String: Any]) {

        guard
            let id = json["id"] as? Int,
            let title = json["title"] as? String,
            let body = json["body"] as? String,
            let userId = json["userId"] as? Int
        else {
            return nil
        }

        self.id = id
        self.title = title
        self.body = body
        self.userId = userId
    }

    static func getArray(from jsonArray: Any) -> [Post]? {

        guard let jsonArray = jsonArray as? Array<[String: Any]> else { return nil }
        var posts: [Post] = []

        for jsonObject in jsonArray {
            if let post = Post(json: jsonObject) {
                posts.append(post)
            }
        }
        return posts
    }
}

Для тех кто уже разобрался в работе с flatMap, то функцию getArray можно написать так:

    static func getArray(from jsonArray: Any) -> [Post]? {
        guard let jsonArray = jsonArray as? Array<[String: Any]> else { return nil }
        return jsonArray.flatMap { Post(json: $0) }
    }

Разные типы ответов

responseJSON

Как отправлять запрос и получать ответ в виде JSON с помощью responseJSON мы научились. Теперь разберем в каком еще виде можем получить ответ.

responseData

Ответ нам придет в виде Data. Зачастую так приходят картинки, но даже наш предыдущий запрос мы можем получть в виде Data:

request("http://jsonplaceholder.typicode.com/posts").responseData { responseData in

    switch responseData.result {
    case .success(let value):
        guard let string = String(data: value, encoding: .utf8) else { return }
        print(string)

    case .failure(let error):
        print(error)
    }
}

В примере мы получает ответ и преобразовываем его в строку. Из нее неудобно получать данные, как из Dictionary, но есть парсеры, которые сделают из стоки объект.

responseString

Здесь все просто. Ответ придет в виде JSON строки. По факту он делает, то, что мы написали выше в responseData:

request("http://jsonplaceholder.typicode.com/posts").responseString { responseString in

    switch responseString.result {
    case .success(let value):
        print(value)

    case .failure(let error):
        print(error)
    }
}

response

Можно сказать это базовый метод. Он никак не обрабатывает данные от сервера, выдает их в том виде, в каком они пришли. У него нету свойства result и поэтому конструкция вида switch response.result здесь не сработает. Все придется делать вручную. Он нам редко понадобится, но знать о нем надо.

request("http://jsonplaceholder.typicode.com/posts").response { response in
    guard
        let data = response.data,
        let string = String(data: data, encoding: .utf8)
        else { return }
    print(string)
}

Выведется строка, если ответ пришел без ошибки.

responsePropertyList

Существует еще метод .responsePropertyList. Он нужен для получения распарсенного plist файла. Я им еще не пользовался и не нашел тестого сервера, чтобы привести пример. Просто знайте, что он есть или можете сами с ним разобраться по аналогии с другими.

Прогресс загрузки

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

Вместо https://s-media-cache-ak0.pinimg.com/originals/ef/6f/8a/ef6f8ac3c1d9038cad7f072261ffc841.jpg можете вставить любую ссылку на фотографию. Желательно большую, чтобы запрос не выполнился моментально и вы увидели сам процесс.

request("https://s-media-cache-ak0.pinimg.com/originals/ef/6f/8a/ef6f8ac3c1d9038cad7f072261ffc841.jpg")
    .validate()
    .downloadProgress { progress in
        print("totalUnitCount:n", progress.totalUnitCount)
        print("completedUnitCount:n", progress.completedUnitCount)
        print("fractionCompleted:n", progress.fractionCompleted)
        print("localizedDescription:n", progress.localizedDescription)
        print("---------------------------------------------")
    }
    .response { response in
        guard
            let data = response.data,
            let image = UIImage(data: data)
            else { return }
        print(image)
}

Класс Progress — это класс стандартной библиотеки.

В логах будет выводиться прогресс в виде блоков:

totalUnitCount:
 2113789
completedUnitCount:
 2096902
fractionCompleted:
 0.992011028536907
localizedDescription:
 99% completed

Мы можем поделить completedUnitCount на totalUnitCount и получим число от 0 до 1, которое будет использоваться в UIProgressView, но за нас это уже сделали в свойстве fractionCompleted.

Чтобы увидеть саму картинку, поставьте breakpoint на строку с print(image) и нажмите на Quick Look (кнопка с глазом) в дебаг панели:

![Debug console](/Users/zdaecqzezdaecq/Downloads/Работа с запросам с помощью Alamofire/image_quick_look.png)

Примеры

Создание объекта (POST)

Самое простое создание объекта на сервере выглядит так:

let params: [String: Any] = [
    "title": "new post",
    "body": "some news",
    "userId": 10
]

request("http://jsonplaceholder.typicode.com/posts", method: .post, parameters: params).validate().responseJSON { responseJSON in

    switch responseJSON.result {
    case .success(let value):
        guard
            let jsonObject = value as? [String: Any],
            let post = Post(json: jsonObject)
            else { return }
        print(post)

    case .failure(let error):
        print(error)
    }
}

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

Обновление объекта (PUT)

При обновлении объекта, его id зачастую прописывается не в параметре, а в пути запроса (~/posts/1):

let params: [String: Any] = [
    "title": "new post",
    "body": "some news",
    "userId": 10
]

request("http://jsonplaceholder.typicode.com/posts/1", method: .put, parameters: params).validate().responseJSON { responseJSON in

    switch responseJSON.result {
    case .success(let value):
        guard
            let jsonObject = value as? [String: Any],
            let post = Post(json: jsonObject)
            else { return }
        print(post)

    case .failure(let error):
        print(error)
    }
}

Конечно, могут сделать и через параметр, но это будет не по REST. Подробнее про REST в статье на хабре:

Архитектура REST [35]

Загрузка фотографии на сервер (multipartFormData)

Так выглядит загрузка фотографии на сервер:

let image = UIImage(named: "some_photo")!
let data = UIImagePNGRepresentation(image)!

let httpHeaders = ["Authorization": "Basic YWNjXzE4MTM2ZmRhOW*****A=="]

upload(multipartFormData: { multipartFormData in
    multipartFormData.append(data, withName: "imagefile", fileName: "image.jpg", mimeType: "image/jpeg")
}, to: "https://api.imagga.com/v1/content", headers: httpHeaders, encodingCompletion: { encodingResult in
    switch encodingResult {
    case .success(let uploadRequest, let streamingFromDisk, let streamFileURL):
        print(uploadRequest)
        print(streamingFromDisk)
        print(streamFileURL ?? "streamFileURL is NIL")

        uploadRequest.validate().responseJSON() { responseJSON in
            switch responseJSON.result {
            case .success(let value):
                print(value)

            case .failure(let error):
                print(error)
            }
        }

    case .failure(let error):
        print(error)
    }
})

Ужасно не правда ли?

Давайте разберем, что за что отвечает.

Я закинул фотографию с именем some_photo в Assets.xcassets

Создаем объект картинки и преобразуем ее в Data:

let image = UIImage(named: "some_photo")!
let data = UIImagePNGRepresentation(image)!

Создаем словарь для передачи токена авторизации:

let httpHeaders = ["Authorization": "Basic YWNjXzE4MTM2ZmRhOW*****A=="]

Это необходимо т.к. сервис www.imagga.com требует авторизацию, чтобы залить картинку.

Чтобы получить свой токен, вам необходимо всего лишь зарегистрироваться на их сайте и скопировать его из своего профиля по ссылке: https://imagga.com/profile/dashboard [36]

До этого мы использовали метод request. Сдесь же используется метод upload. Первым параметром идет клоужер для присоединения нашей картинки:

upload(multipartFormData: { multipartFormData in
    multipartFormData.append(data, withName: "imagefile", fileName: "image.jpg", mimeType: "image/jpeg")
}

Следующими параметрами идут URL и headers:

to: "https://api.imagga.com/v1/content", headers: httpHeaders

Дальше идет клоужер с закодированным запросом:

encodingCompletion: { encodingResult in
    switch encodingResult {
    case .success(let uploadRequest, let streamingFromDisk, let streamFileURL):
        print(uploadRequest)
        print(streamingFromDisk)
        print(streamFileURL ?? "streamFileURL is NIL")
        ...

    case .failure(let error):
        print(error)
    }
})

Из него мы можем получить запрос (uploadRequest), и две переменные необходимые для потока(stream) файлов.

Про потоки говорить не буду, достаточно редкая штука. Пока вы просто увидите, что эти две переменные равны false и nil соответственно.

Дальше мы должны отправить запрос в привычной для нас форме:

uploadRequest.validate().responseJSON() { responseJSON in
    switch responseJSON.result {
    case .success(let value):
        print(value)

    case .failure(let error):
        print(error)
    }
}

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

{
    status = success;
    uploaded =     (
                {
            filename = "image.jpg";
            id = 83800f331a7f97e41e0f0b70bf7847bd;
        }
    );
}

filename может не отличаться, а id будут.

Итог

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

Автор: bonyadmitr

Источник [37]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/ios-development/257802

Ссылки в тексте:

[1] Зачем: #why

[2] Установка: #setup

[3] Настройка доступа HTTP: #setup-http

[4] Первый минимальный запрос: #first-request

[5] Подробнее о минимуме: #more-about-minimum

[6] Методы HTTP: #http-methods

[7] Alamofire.request: #alamofire-request

[8] URLConvertible: #urlconvertible

[9] HTTPMethod: #httpmethod

[10] Parameters: #parameters

[11] ParameterEncoding: #parameterencoding

[12] HTTPHeaders: #httpheaders

[13] DataRequest: #datarequest

[14] Обработка ответа: #response-processing

[15] Ручная обработка ответа: #manual-response-processing

[16] Настройка запроса: #request-setup

[17] Обработка результата ответа: #processing-response-result

[18] Разные типы ответов: #different-answers

[19] responseJSON: #responsejson

[20] responseData: #responsedata

[21] responseString: #responsestring

[22] response: #response

[23] responsePropertyList: #responsepropertylist

[24] Прогресс загрузки: #download-progress

[25] Примеры: #examples

[26] Создание объекта (POST): #create-object

[27] Обновление объекта (PUT): #update-object

[28] Загрузка фотографии на сервер (multipartFormData): #image-upload

[29] Итог: #total

[30] http://jsonplaceholder.typicode.com: http://jsonplaceholder.typicode.com

[31] Протокол HTTP: https://ru.wikipedia.org/wiki/HTTP

[32] Методы HTTP: https://ru.wikipedia.org/wiki/HTTP#.D0.9C.D0.B5.D1.82.D0.BE.D0.B4.D1.8B

[33] Заголовки HTTP: https://ru.wikipedia.org/wiki/HTTP#.D0.97.D0.B0.D0.B3.D0.BE.D0.BB.D0.BE.D0.B2.D0.BA.D0.B8

[34] Коды состояния HTTP: https://ru.wikipedia.org/wiki/HTTP#.D0.9A.D0.BE.D0.B4.D1.8B_.D1.81.D0.BE.D1.81.D1.82.D0.BE.D1.8F.D0.BD.D0.B8.D1.8F

[35] Архитектура REST: https://habrahabr.ru/post/38730/

[36] https://imagga.com/profile/dashboard: https://imagga.com/profile/dashboard

[37] Источник: https://habrahabr.ru/post/330760/