Содержание:
- Что такое REST API
- Архитектура REST
- Исходные данные
- Настройка бэкенда REST API
- Создание пользовательского модуля
- Создание модуля «auth»
- Создание middleware-компонентов разрешений и валидации
- Запуск и тестирование REST API с Insomnia
- Следующие этапы
Что такое REST API
Интерфейсы прикладного программирования или API (Application Programming Interface) применяются в разработке повсеместно. Они позволяют одним программам последовательно взаимодействовать с другими — внутренними или внешними — программными компонентами. Это является ключевым условием масштабируемости, не говоря уже о возможности повторного использования приложений.
В настоящее время довольно распространены онлайн-сервисы, использующие общедоступные API. Они дают возможность другим разработчикам легко интегрировать такие функции, как авторизация через соцсети, платежи кредитной картой и отслеживание поведения.
Применяемый при этом стандарт де-факто называется «передачей состояния представления» (REpresentational State Transfer) или сокращённо REST. Простыми словами, REST API — это набор правил, по которым серверные приложения взаимодействуют с клиентскими.
Для создания простого, но безопасного бэкенда на основе REST API может быть задействовано множество платформ и языков программирования, например ASP.NET Core, Laravel (PHP) или Bottle (Python).
В этой же статье будет использоваться следующий инструментарий:
- js — как пример распространённой кроссплатформенной среды выполнения JavaScript.
- Express, который значительно упрощает выполнение основных задач веб-сервера в Node.js и является стандартным инструментом для создания серверной части на основе REST API.
- Mongoose, который будет соединять наш бэкенд с базой данных MongoDB.
Помимо вышеперечисленного, пользователям данного руководства необходимо уверенно владеть навыками работы с терминалом (или командной строкой).
Обратите внимание! В рамках инструкции не будет рассмотрена работа с кодовой базой фронтенда. Тем не менее, можно легко использовать код (например, объектные модели на стороне сервера и клиента), так как наш бэкенд написан на JavaScript.
Архитектура REST
Чтобы понять, как работает REST API, нужно подробнее рассмотреть, что представляет собой стиль архитектуры программного обеспечения REST.
REST API используются для доступа к данными и их обработки с помощью стандартного набора операций без сохранения состояния. Эти операции являются неотъемлемой частью протокола HTTP. Они представляют собой основные функции создания («create»), чтения («read»), модификации («update») и удаления («delete») и обозначаются акронимом CRUD.
Операциям REST API соответствуют, хотя и не полностью идентичны, следующие методы HTTP:
- POST (создание ресурса или предоставление данных в целом).
- GET (получение индекса ресурсов или отдельного ресурса).
- PUT (создание и замена ресурса).
- PATCH (обновление/изменение ресурса).
- DELETE (удаление ресурса).
С помощью этих HTTP-методов и имени ресурса в качестве адреса, мы можем построить REST API, создав конечную точку для каждой операции. В результате мы получим стабильную и легко понятную основу, которая позволит быстро дорабатывать код и осуществлять его дальнейшее сопровождение.
Та же основа будет применяться для интеграции сторонних функций, о которых было сказано чуть выше. Большинство из них тоже использует REST API, что ускоряет такую интеграцию.
Исходные данные
В этом руководстве мы создадим самый простой REST API с нуля для ресурса под названием «users» с помощью Node.js.
У ресурса «users» будет следующая базовая структура:
- id (UUID, что значит «автоматически сгенерированный универсальный уникальный идентификатор»).
- firstName (Имя)
- lastName (Фамилия)
- email (Электронная почта)
- password (Пароль)
- permissionLevel (что разрешено делать данному пользователю).
Для этого ресурса будут созданы следующие операции:
- «POST» в конечной точке «/users» (создание нового пользователя).
- «GET» в конечной точке «/users» (вывод списка всех пользователей).
- «GET» в конечной точке «/users/:userId» (получение конкретного пользователя).
- «PATCH» в конечной точке «/users/:userId» (обновление данных для конкретного пользователя).
- «DELETE» в конечной точке «/users/:userId» (удаление конкретного пользователя).
Кроме того, будет использоваться стандарт «JSON web token» для создания токенов доступа.
Поэтому мы создадим ещё один ресурс под названием «auth», который будет принимать адрес электронной почты и пароль пользователя. А в ответ — генерировать токен для аутентификации при определённых операциях.
Настройка бэкенда REST API
Прежде всего убедитесь, что у вас установлена самая последняя версия Node.js. Здесь будем использовать версию 14.9.0, однако могут подойти и более старые.
Затем позаботьтесь о том, чтобы у вас была установлена MongoDB. Не будем подробно расписывать особенности используемых здесь Mongoose и MongoDB.
Просто запустите сервер в интерактивном режиме (введя в командной строке «mongo»), а не как сервис. Дело в том, что в какой-то момент нам нужно будет взаимодействовать с MongoDB напрямую, а не через код Node.js.
Обратите внимание! С MongoDB нет необходимости создавать конкретную базу данных, как это бывает в сценариях некоторых реляционных СУБД. Первый вызов «insert» из кода Node.js инициирует её создание автоматически.
Данная статья не содержит всего кода, необходимого для рабочего проекта. Предполагается, что вы клонируете дополнительный репозиторий и просто следуете за основными пунктами, по мере чтения этого руководства. Если хотите, можете при необходимости копировать конкретные файлы и фрагменты кода из этого репозитория.
Перейдите к появившейся папке «rest-api-tutorial/» в терминале. Здесь находятся три папки модулей проекта:
- «common» — все общие сервисы и информация, совместно используемая пользовательскими модулями.
- «users» — всё, что касается пользователей.
- «auth» — обработка генерации токенов JWT (JSON Web Token) и поток авторизации.
Теперь запустите «npm install» (или «yarn», если он у вас есть).
Поздравляем, теперь у вас есть все зависимости и настройки, необходимые для запуска простого бэкенда на базе REST API.
Создание пользовательского модуля
Мы будем использовать Mongoose — ODM-библиотеку для MongoDB. С её помощью создадим модель «user» (пользователь) в рамках схемы «user» (пользователь).
Первым делом нужно создать схему для библиотеки Mongoose в «/users/models/users.model.js»:
const userSchema = new Schema({ firstName: String, lastName: String, email: String, password: String, permissionLevel: Number });
После определения схемы можно легко присоединить её к модели «user».
const userModel = mongoose.model('Users', userSchema);
Теперь можем использовать эту модель для реализации всех операций «CRUD», которые нам нужны в конечных точках Express.
Начнём с операции создания пользователя, определив маршрут в «users/routes.config.js»:
app.post('/users', [ UsersController.insert ]);
Этот код добавляется в основной файл «index.js» приложения на Express. Объект «UsersController» импортируется из нашего контроллера, где мы соответствующим образом хешируем пароль, определённый в «/users/controllers/users.controller.js»:
exports.insert = (req, res) => { let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512',salt) .update(req.body.password) .digest("base64"); req.body.password = salt + "$" + hash; req.body.permissionLevel = 1; UserModel.createUser(req.body) .then((result) => { res.status(201).send({id: result._id}); }); };
Теперь мы можем протестировать нашу модель Mongoose, запустив сервер («npm start») и отправив запрос «POST» в «/users» с данными в формате JSON:
{ "firstName" : "Marcos", "lastName" : "Silva", "email" : "marcos.henrique@toptal.com", "password" : "s3cr3tp4sswo4rd" }
Для этого есть соответствующий инструментарий. Во-первых, это Insomnia (его рассмотрим далее) и Postman — популярные инструменты с графическим интерфейсом, а также curl — утилита командной строки.
Можно даже использовать один только JavaScript. Например, из встроенной консоли средств разработки вашего браузера:
fetch('http://localhost:3600/users', { method: 'POST', headers: { "Content-type": "application/json" }, body: JSON.stringify({ "firstName": "Marcos", "lastName": "Silva", "email": "marcos.henrique@toptal.com", "password": "s3cr3tp4sswo4rd" }) }) .then(function(response) { return response.json(); }) .then(function(data) { console.log('Request succeeded with JSON response', data); }) .catch(function(error) { console.log('Request failed', error); });
Здесь результатом валидного «POST» будет только лишь идентификатор «id» от созданного пользователя:
{ "id": "5b02c5c84817bf28049e58a3" }
Нам также нужно добавить метод «createUser» к модели в «users/models/users.model.js»:
exports.createUser = (userData) => { const user = new User(userData); return user.save(); };
Теперь надо убедиться, что пользователь существует. Для этого реализуем функцию «get user by id» для следующей конечной точки, а именно «users/:userId».
Первым делом создаём маршрут в «/users/routes/config.js»:
app.get('/users/:userId', [ UsersController.getById ]);
Затем создаём контроллер в «/users/controllers/users.controller.js»:
exports.getById = (req, res) => { UserModel.findById(req.params.userId).then((result) => { res.status(200).send(result); }); };
И, наконец, добавляем метод «findById» к модели в «/users/models/users.model.js»:
exports.findById = (id) => { return User.findById(id).then((result) => { result = result.toJSON(); delete result._id; delete result.__v; return result; }); };
Ответ будет примерно таким:
{ "firstName": "Marcos", "lastName": "Silva", "email": "marcos.henrique@toptal.com", "password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }
Обратите внимание! Здесь мы видим хешированный пароль. В данном руководстве мы показываем пароль, но лучше никогда этого не делать, даже если пароль хеширован. Ещё у нас здесь есть «permissionLevel», который мы будем использовать для обработки разрешений пользователя.
Повторяя изложенный выше порядок действий, теперь можно добавить функциональность для обновления пользователя. Будем использовать операцию «PATCH», позволяющую отправлять только те поля, которые мы хотим изменить. Таким образом, мы будем отправлять любые поля, которые захотим изменить, по маршруту от «PATCH» к «/users/:userid».
Кроме того, потребуется провести дополнительную проверку. Ведь изменения должны затрагивать только соответствующего пользователя или администратора. И только администратор должен иметь возможность менять «permissionLevel» (уровень допуска).
Пока что оставим эту часть и вернёмся к ней после того, как реализуем модуль «auth». Сейчас наш контроллер будет выглядеть так:
exports.patchById = (req, res) => { if (req.body.password){ let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); req.body.password = salt + "$" + hash; } UserModel.patchUser(req.params.userId, req.body).then((result) => { res.status(204).send({}); }); };
Здесь мы по умолчанию будем отправлять HTTP-код «204» без тела ответа, чтобы показать, что запрос был успешным.
И нужно будет добавить к модели метод «patchUser»:
exports.patchUser = (id, userData) => { return User.findOneAndUpdate({ _id: id }, userData); };
Список пользователей будет реализован как «GET» в «/users/» следующим контроллером:
exports.list = (req, res) => { let limit = req.query.limit && req.query.limit <= 100 ? parseInt(req.query.limit) : 10; let page = 0; if (req.query) { if (req.query.page) { req.query.page = parseInt(req.query.page); page = Number.isInteger(req.query.page) ? req.query.page : 0; } } UserModel.list(limit, page).then((result) => { res.status(200).send(result); }) };
Соответствующим методом модели будет:
exports.list = (perPage, page) => { return new Promise((resolve, reject) => { User.find() .limit(perPage) .skip(perPage * page) .exec(function (err, users) { if (err) { reject(err); } else { resolve(users); } }) }); };
Ответ в результирующем списке будет иметь следующую структуру:
[ { "firstName": "Marco", "lastName": "Silva", "email": "marcos.henrique@toptal.com", "password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }, { "firstName": "Paulo", "lastName": "Silva", "email": "marcos.henrique2@toptal.com", "password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==", "permissionLevel": 1, "id": "5b02d038b653603d1ca69729" } ]
Теперь реализуем последнюю часть, «DELETE» в «/users/:userId». Наш контроллер для удаления:
exports.removeById = (req, res) => { UserModel.removeById(req.params.userId) .then((result)=>{ res.status(204).send({}); }); };
Контроллер снова вернёт HTTP-код «204» с пустым телом ответа в качестве подтверждения.
Соответствующий метод модели должен выглядеть следующим образом:
exports.removeById = (userId) => { return new Promise((resolve, reject) => { User.deleteMany({_id: userId}, (err) => { if (err) { reject(err); } else { resolve(err); } }); }); };
Теперь у нас есть все необходимые операции для манипулирования ресурсом «user» (пользователь) и готов контроллер пользователя.
Приведённый здесь код должен сформировать представление об основных принципах использования REST. К этому коду нам предстоит ещё вернуться для проведения проверок и изменения разрешений, но сначала нужно обеспечить безопасность. Для этого мы создадим модуль аутентификации.
Создание модуля «auth»
Прежде чем обезопасить модуль «users» с помощью добавления middleware-компонентов (ПО промежуточной обработки) для обеспечения разрешений и валидации, нужно создать валидный токен текущего пользователя.
В качестве токена используем JSON web token (JWT). Это стандарт для создания токенов доступа, основанный на формате JSON, который можно использовать для безопасного выполнения пользователем нескольких запросов без повторной проверки. Мы сгенерируем JWT в ответ на предоставление пользователем валидного адреса электронной почты и пароля.
Обычно у токена есть определённое время действия: для обеспечения безопасности передачи данных каждые несколько минут создаётся новый токен. Но в данном руководстве мы воздержимся от обновления токена. Для простоты ограничимся одним-единственным токеном на каждую авторизацию.
Во-первых, создадим конечную точку для POST-запросов к ресурсу «/auth». Тело запроса будет содержать адрес электронной почты пользователя и пароль:
{ "email" : "marcos.henrique2@toptal.com", "password" : "s3cr3tp4sswo4rd2" }
Прежде чем задействовать контроллер, нужно проверить пользователя в «/authorization/middlewares/verify.user.middleware.js»:
exports.isPasswordAndUserMatch = (req, res, next) => { UserModel.findByEmail(req.body.email) .then((user)=>{ if(!user[0]){ res.status(404).send({}); }else{ let passwordFields = user[0].password.split('$'); let salt = passwordFields[0]; let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); if (hash === passwordFields[1]) { req.body = { userId: user[0]._id, email: user[0].email, permissionLevel: user[0].permissionLevel, provider: 'email', name: user[0].firstName + ' ' + user[0].lastName, }; return next(); } else { return res.status(400).send({errors: ['Invalid email or password']}); } } }); };
После этого можно перейти к контроллеру и сгенерировать JWT:
exports.login = (req, res) => { try { let refreshId = req.body.userId + jwtSecret; let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(refreshId).digest("base64"); req.body.refreshKey = salt; let token = jwt.sign(req.body, jwtSecret); let b = Buffer.from(hash); let refresh_token = b.toString('base64'); res.status(201).send({accessToken: token, refreshToken: refresh_token}); } catch (err) { res.status(500).send({errors: err}); } };
Несмотря на то, что токен у нас в статье обновляться не будет, контроллер мы настроили таким образом, чтобы такую генерацию легко можно было реализовывать в последующей разработке.
Теперь нам нужно лишь создать маршрут и вызвать соответствующий middleware-компонент в «/authorization/routes.config.js»:
app.post('/auth', [ VerifyUserMiddleware.hasAuthValidFields, VerifyUserMiddleware.isPasswordAndUserMatch, AuthorizationController.login ]);
В ответе будет сгенерированный JWT (в поле «accessToken»):
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY", "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ==" }
Создав токен, мы можем использовать его внутри заголовка «Authorization» с помощью формы «Bearer ACCESS_TOKEN».
Создание middleware-компонентов разрешений и валидации
Первым делом нужно определить, кто может использовать ресурс «users». Вот сценарии, с которыми нам нужно будет иметь дело:
- Общедоступный для создания пользователей (процесс регистрации). В этом сценарии JWT использоваться не будет.
- Закрытый для авторизовавшегося пользователя и для администраторов, обновляющих этого пользователя.
- Закрытый для администратора только для удаления учётных записей пользователей.
Определившись со сценариями, перейдём к middleware-компонентам, которые всегда проверяют, использует ли пользователь валидный JWT.
Добавить middleware компонент в «/common/middlewares/auth.validation.middleware.js» можно также легко, как в примере ниже:
if (req.headers['authorization']) { try { let authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { req.jwt = jwt.verify(authorization[1], secret); return next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } };
Для обработки ошибок запроса будем использовать коды ошибок HTTP:
- «HTTP 401» для недопустимого запроса.
- «НТТР 403» для валидного запроса с неверным токеном или для валидного токена с недопустимыми разрешениями.
Для управления разрешениями можно использовать побитовый оператор «И» (AND, &). Если возвести каждое требуемое разрешение во вторую степень, можно рассматривать каждый бит 32-битного целого числа как одно разрешение.
Администратор может получить все разрешения, установив их значение равным «2147483647». В данном случае этот пользователь будет иметь возможность получить доступ к любому маршруту.
Другой пример: пользователь, значение разрешения которого равно семи, будет иметь разрешения к правам доступа, отмеченным битами для значений 1, 2 и 4 (два в нулевой, первой и второй степени).
Middleware-компонент будет выглядеть следующим образом:
exports.minimumPermissionLevelRequired = (required_permission_level) => { return (req, res, next) => { let user_permission_level = parseInt(req.jwt.permission_level); let user_id = req.jwt.user_id; if (user_permission_level & required_permission_level) { return next(); } else { return res.status(403).send(); } }; };
Компоненты middleware универсальны. Если уровень разрешений пользователя и необходимый уровень разрешений совпадают хотя бы в одном бите, результат будет больше нуля и действие может продолжаться. В противном случае будет возвращён HTTP-код «403».
Теперь нужно добавить middleware для аутентификации к маршрутам модуля «user» в «/users/routes.config.js»:
app.post('/users', [ UsersController.insert ]); app.get('/users', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(PAID), UsersController.list ]); app.get('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.getById ]); app.patch('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.patchById ]); app.delete('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(ADMIN), UsersController.removeById ]);
На этом завершается основная часть разработки REST API. Остаётся только всё это протестировать.
Запуск и тестирование REST API с Insomnia
В Insomnia есть хорошая бесплатная версия клиента REST. Будем использовать здесь данный инструмент как приложение, помогающее понять, что происходит с нашим API.
Лучше всего, конечно, сразу внедрять в проект тестирования кода и точную отчётность об ошибках. Но, когда данные службы недоступны, сторонний клиент REST отлично справляется с этой задачей.
Чтобы создать пользователя, нужно просто отправить с помощью «POST» необходимые поля в соответствующую конечную точку и сохранить сгенерированный идентификатор ID для последующего использования.
В ответ с API придёт идентификатор пользователя:
Теперь с помощью конечной точки «/auth/» можно сгенерировать JWT:
В ответе мы должны получить токен:
Берём «accessToken», ставим впереди него «Bearer» (не забываем о пробеле) и добавляем его в заголовки запроса внутри «Authorization»:
Если не сделать этого, то на каждый запрос, кроме регистрации, будет возвращаться HTTP-код «401». Тем более сейчас, когда мы внедрили middleware для разрешений. Но у нас есть валидный токен, поэтому от «/users/:userId» мы получаем следующий ответ:
Ранее здесь уже упоминалось, что в учебных целях и ради простоты понимания мы показываем все поля. На практике пароль (хешированный или другой) никогда не должен быть виден в ответе.
Попробуем получить список пользователей:
Неожиданно мы получаем ответ «403».
У нашего пользователя нет разрешений на доступ к этой конечной точке. Придётся поменять «permissionLevel» пользователя с 1 до 7 (даже «5» будет достаточно, так как наши бесплатные и платные уровни разрешений представлены значениями «1» и «4» соответственно.)
Это можно сделать вручную. Например, в интерактивной консоли MongoDB (идентификатор ID поменяйте на свой):
db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})
Затем нужно сгенерировать новый JWT. После чего мы получим нужный ответ:
Теперь протестируем обновлённый функционал, отправив запрос «PATCH» с произвольными полями в нашу конечную точку «/users/:userId»:
В ответ должен прийти «204» в качестве подтверждения успешности операции. Однако мы можем ещё раз запросить пользователя, чтобы убедиться наверняка.
И, наконец, нужно удалить пользователя. Потребуется создать нового пользователя, как мы это уже делали выше. Не забудьте обозначить его ID и позаботьтесь, чтобы для пользователя-администратора имелся соответствующий JWT.
Новый пользователь должен будет иметь разрешения со значением «2053» (т. е. «2048» — ADMIN — плюс наши предыдущие «5»), чтобы иметь возможность также выполнить операцию удаления.
После того как всё будет готово и сгенерирован новый JWT, нужно будет обновить заголовок запроса «Authorization»:
Отправив запрос «DELETE» на «/users/:userId», мы должны получить ответ «204» в качестве подтверждения. Можно дополнительно убедиться в удалении, отправив запрос ««/users/»» для получения списка всех имеющихся пользователей.
Следующие этапы создания REST API
Теперь с помощью инструментов и методов, рассмотренных в этом руководстве, вы сможете создавать простые и безопасные REST API на Node.js. Многое из того, что не является существенным для работы с REST API, было опущено. Так что не забывайте:
- Проводить проверки валидности формы (например, чтобы электронная почта пользователя была уникальной).
- Внедрять модульное тестирование и отчётность об ошибках.
- Не допускать изменения уровня разрешений самими пользователями.
- Не допускать возможности администраторам удалять самих себя.
- Не допускать разглашения конфиденциальной информации (например, хешированных паролей).
- Помещать пароли к токену и ключи авторизации из «common/config/env.config.js» в механизм их распределения, находящийся во внешнем хранилище.
В заключение читатель может попробовать перейти от использования в кодовой базе промисов JavaScript к применению «async/await».
Нужна надёжная база для разработки веб-приложений? Выбирайте виртуальные сервера от Eternalhost с технической поддержкой 24/7 и бесплатной защитой от DDoS!
Автор оригинала: Marcos Henrique da Silva