Разработка сайтов в Светлодарске, ДНР. Создайте Rest API для Jamstack с помощью Hapi и TypeScript

 
 

У Jamstack есть хороший способ отделить переднюю часть от задней части, где все решение не должно поставляться в виде единого монолита — и все это в одно и то же время. Когда Jamstack работает в паре с REST API, клиент и API могут развиваться независимо друг от друга. Это означает, что передняя и задняя части не связаны жестко, и изменение одного не обязательно означает изменение другого.

В этой статье я рассмотрю REST API с точки зрения Jamstack. Я покажу, как развивать API, не нарушая существующие клиенты и придерживаясь стандартов REST. Я выберу Hapi в качестве предпочтительного инструмента для создания API и Joi для проверки конечных точек. Уровень сохраняемости базы данных перейдет в MongoDB через Mongoose для доступа к данным. Разработка через тестирование поможет мне повторять изменения и обеспечит быстрый способ получения обратной связи с меньшей когнитивной нагрузкой. В конце концов, цель состоит в том, чтобы вы увидели, как REST и Jamstack могут предоставить решение с высокой связностью и низкой связанностью между программными модулями. Этот тип архитектуры лучше всего подходит для распределенных систем с множеством микросервисов, каждый из которых находится в своем отдельном домене. Я предполагаю, что у меня есть практические знания NPM, ES6+ и базовое знакомство с конечными точками API.

API будет работать с данными автора, с именем, адресом электронной почты и дополнительным отношением 1: N (один к нескольким через встраивание документа) в избранных темах. Я напишу конечные точки GET, PUT (с upsert) и DELETE. Для тестирования API fetch () подойдет любой поддерживающий клиент, поэтому я выберу Hoppscotch и CURL.

Я буду держать поток чтения этой части как учебник, где вы можете следовать сверху вниз. Для тех, кто предпочитает пропустить код, он доступен на GitHub для вашего удовольствия. В этом руководстве предполагается, что у вас есть рабочая версия Node (желательно последняя LTS) и уже установленная MongoDB.

Начальная настройка

Чтобы запустить проект с нуля, создайте папку и cdв нее:

mkdir hapi-authors-rest-api

cd hapi-authors-rest-api

Оказавшись в папке проекта, запустите npm initи следуйте инструкциям. Это создает package.jsonв корне папки.

У каждого проекта Node есть зависимости. Для начала мне понадобятся Hapi, Joi и Mongoose:

npm i @hapi/hapi joi mongoose —save-exact

@hapi/hapi: структура сервера HTTP REST

Joi: мощный валидатор схемы объекта

Mongoose: моделирование объектных документов MongoDB

Проверьте, package.jsonчтобы убедиться, что все зависимости и настройки проекта установлены. Затем добавьте точку входа в этот проект:

«scripts»: {

«start»: «node index.js»

},

Структура папок MVC с управлением версиями

Для этого REST API я буду использовать типичную структуру папок MVC с контроллерами, маршрутами и моделью базы данных. Контроллер будет иметь версию AuthorV1Controller, позволяющую API развиваться при критических изменениях в модели. У Hapi будет возможность server.jsсделать index.jsэтот проект тестируемым с помощью разработки через тестирование. Папка testбудет содержать модульные тесты.

Ниже приведена общая структура папок:

┣━┓ config

┃ ┣━━ dev.json

┃ ┗━━ index.js

┣━┓ controllers

┃ ┗━━ AuthorV1Controller.js

┣━┓ model

┃ ┣━━ Author.js

┃ ┗━━ index.js

┣━┓ routes

┃ ┣━━ authors.js

┃ ┗━━ index.js

┣━┓ test

┃ ┗━━ Author.js

┣━━ index.js

┣━━ package.json

┗━━ server.js

А пока создайте папки и соответствующие файлы внутри каждой папки.

mkdir config controllers model routes test

touch config/dev.json config/index.js controllers/AuthorV1Controller.js model/Author.js model/index.js routes/authors.js routes/index.js test/Authors.js index.js server.js

Вот для чего предназначена каждая папка:

config: информация о конфигурации для подключения к соединению Mongoose и серверу Hapi.

controllers: это обработчики Hapi, которые работают с объектами Request/Response. Управление версиями позволяет использовать несколько конечных точек для каждого номера версии, то есть, /v1/authors, /v2/authorsи т. д.

model: подключается к базе данных MongoDB и определяет схему Mongoose.

routes: определяет конечные точки с проверкой Joi для приверженцев REST.

test: модульные тесты с помощью лабораторного инструмента Hapi. (Подробнее об этом позже.)

В реальном проекте может оказаться полезным абстрагировать общую бизнес-логику в отдельную папку, скажем, utils. Я рекомендую создать AuthorUtil.jsмодуль с чисто функциональным кодом, чтобы его можно было повторно использовать на конечных точках и было легко проводить модульное тестирование. Поскольку в этом решении нет сложной бизнес-логики, я пропущу эту папку.

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

Машинопись

Чтобы улучшить опыт разработчиков, я теперь добавлю объявления типов TypeScript. Поскольку Mongoose и Joi определяют модель во время выполнения, добавление средства проверки типов во время компиляции не имеет особого смысла. В TypeScript можно добавлять определения типов в обычный проект JavaScript и по-прежнему пользоваться преимуществами проверки типов в редакторе кода. Такие инструменты, как WebStorm или VS Code, подберут определения типов и позволят программисту «расставить точки» в коде. Этот метод часто называют IntelliSense, и он включается, когда в среде IDE есть доступные типы. С этим вы получаете хороший способ определить программный интерфейс, чтобы разработчики могли расставлять точки над объектами, не заглядывая в документацию. Редактор тоже иногда выдает предупреждения, когда разработчики ставят точку не на тот объект.

Вот как выглядит IntelliSense в VS Code:

VSCode IntelliSense

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

Чтобы включить объявления типов TypeScript в этом проекте, запустите NPM и сохраните эти зависимости разработчика:

npm i @types/hapi @types/mongoose —save-dev

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

Со всеми тонкостями разработчика пришло время приступить к написанию кода. Откройте server.jsфайл Hapi и поставьте на место основной сервер:

const config = require ('. /config’)

const routes = require ('. /routes’)

const db = require ('. /model’)

const Hapi = require ('@hapi/hapi’)

const server = Hapi.server ({

port: config.APP_PORT,

host: config.APP_HOST,

routes: {

cors: true

}

})

server.route (routes)

exports.init = async () => {

await server.initialize ()

await db.connect ()

return server

}

exports.start = async () => {

await server.start ()

await db.connect ()

console.log (`Server running at: ${server.info.uri}`)

return server

}

process.on ('unhandledRejection’, (err) => {

console.error (err)

process.exit (1)

})

Я включил CORS, установив corsзначение true, чтобы этот REST API мог работать с Hoppscotch.

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

В разделе config/index.jsобязательно экспортируйте dev.jsonинформацию:

module.exports = require ('. /dev’)

Чтобы конкретизировать настройку сервера, введите следующее dev.json:

{

«APP_PORT»: 3000,

«APP_HOST»: «127.0.0.1»

}

ОТДЕЛЬНАЯ валидация

Чтобы конечные точки REST соответствовали стандартам HTTP, я добавлю проверки Joi. Эти проверки помогают отделить API от клиента, поскольку они обеспечивают целостность ресурсов. Для Jamstack это означает, что клиент больше не заботится о деталях реализации каждого ресурса. Каждую конечную точку можно обрабатывать независимо, потому что проверка гарантирует правильный запрос к ресурсу. Придерживаясь строгого стандарта HTTP, клиент развивается на основе целевого ресурса, который находится за границей HTTP, что обеспечивает развязку. На самом деле цель состоит в том, чтобы использовать управление версиями и проверки, чтобы сохранить четкие границы в Jamstack.

Основная цель REST — поддерживать идемпотентность с помощью методов GET, PUT и DELETE. Это безопасные методы запросов, поскольку последующие запросы к тому же ресурсу не имеют побочных эффектов. Тот же предполагаемый эффект повторяется, даже если клиенту не удается установить соединение.

Я предпочитаю пропускать POST и PATCH, так как это небезопасные методы. Это сделано для краткости и идемпотентности, а не потому, что эти методы каким-либо образом жестко связывают клиента. К этим методам могут применяться те же строгие стандарты HTTP, за исключением того, что они не гарантируют идемпотентность.

В routes/authors.js, добавьте следующие проверки Joi:

const Joi = require ('joi’)

const authorV1Params = Joi.object ({

id: Joi.string ().required ()

})

const authorV1Schema = Joi.object ({

name: Joi.string ().required (),

email: Joi.string ().email ().required (),

topics: Joi.array ().items (Joi.string ()), // optional

createdAt: Joi.date ().required ()

})

Обратите внимание, что для любых изменений в версионной модели, скорее всего, потребуется новая версия, например файл v2. Это гарантирует обратную совместимость для существующих клиентов и позволяет API развиваться независимо. Обязательные поля приведут к сбою запроса с ответом 400 (неверный запрос), если поля отсутствуют.

После проверки параметров и схемы добавьте фактические маршруты к этому ресурсу:

// routes/authors.js

const v1Endpoint = require ('.../controllers/AuthorV1Controller’)

module.exports = [{

method: 'GET’,

path: '/v1/authors/{id}',

handler: v1Endpoint.details,

options: {

validate: {

params: authorV1Params

},

response: {

schema: authorV1Schema

}

}

}, {

method: 'PUT’,

path: '/v1/authors/{id}',

handler: v1Endpoint.upsert,

options: {

validate: {

params: authorV1Params,

payload: authorV1Schema

},

response: {

schema: authorV1Schema

}

}

}, {

method: 'DELETE’,

path: '/v1/authors/{id}',

handler: v1Endpoint.delete,

options: {

validate: {

params: authorV1Params

}

}

}]

Чтобы сделать эти маршруты доступными для server.js, добавьте это в routes/index.js:

module.exports = [

...require ('. /authors’)

]

Валидации Joi идут в optionsполе массива маршрутов. Каждый путь запроса принимает параметр строкового идентификатора, который соответствует ObjectIdв MongoDB. Это idчасть версионного маршрута, поскольку это целевой ресурс, с которым должен работать клиент. Для PUT существует проверка полезной нагрузки, которая соответствует ответу GET. Это необходимо для соблюдения стандартов REST, согласно которым ответ PUT должен соответствовать последующему GET.

Вот что написано в стандарте:

Успешный PUT данного представления будет означать, что последующий GET для того же целевого ресурса приведет к отправке эквивалентного представления в ответе 200 (OK).

Это делает неприемлемым для PUT поддержку частичных обновлений, поскольку последующий GET не будет соответствовать PUT. Для Jamstack важно придерживаться стандартов HTTP, чтобы обеспечить предсказуемость для клиентов и разделение.

Обрабатывает AuthorV1Controllerзапрос через обработчик метода в v1Endpoint. Рекомендуется иметь один контроллер для каждой версии, потому что именно он отправляет ответ обратно клиенту. Это упрощает развитие API с помощью нового контроллера версий без нарушения работы существующих клиентов.

Коллекция авторской базы данных

Для объектного моделирования Mongoose для Node сначала необходимо установить базу данных MongoDB. Я рекомендую настроить его на вашем локальном устройстве разработки, чтобы поиграть с MongoDB. Для минимальной установки требуется всего два исполняемых файла, и вы можете запустить и запустить сервер примерно через 50 МБ. В этом настоящая сила MongoDB, потому что полная база данных может работать на очень дешевом оборудовании, таком как Raspberry PI, и масштабируется по горизонтали до любого количества блоков по мере необходимости. База данных также поддерживает гибридную модель, в которой серверы могут работать как в облаке, так и локально. Так что никаких оправданий!

Внутри modelпапки откройте, index.jsчтобы настроить подключение к базе данных:

const config = require ('.../config’)

const mongoose = require ('mongoose’)

module.exports = {

connect: async function () {

await mongoose.connect (

config.DB_HOST + '/' + config.DB_NAME,

config.DB_OPTS)

},

connection: mongoose.connection,

Author: require ('. /Author’)

}

Обратите внимание, что Authorколлекция определяется Author.jsв этой же папке:

const mongoose = require ('mongoose’)

const authorSchema = new mongoose.Schema ({

name: String,

email: String,

topics: [String],

createdAt: Date

})

if (! authorSchema.options.toObject) authorSchema.options.toObject = {}

authorSchema.options.toObject.transform = function (doc, ret) {

delete ret. _id

delete ret. __v

if (ret.topics && ret.topics.length === 0) delete ret.topics

return ret

}

module.exports = mongoose.model ('Author’, authorSchema)

Имейте в виду, что схема Mongoose не соответствует тем же требованиям, что и проверки Joi. Это добавляет гибкости данным для поддержки нескольких версий, если кому-то нужна обратная совместимость для нескольких конечных точек.

Преобразование toObjectочищает вывод JSON, поэтому валидатор Joi не выдает исключение. Если в документе Mongoose есть какие-либо дополнительные поля, такие как _id, сервер отправляет ответ 500 (внутренняя ошибка сервера). Необязательное поле topicsудаляется, когда это пустой массив, потому что GET должен соответствовать ответу PUT.

Наконец, установите конфигурацию базы данных в config/dev.json:

{

«APP_PORT»: 3000,

«APP_HOST»: «127.0.0.1»,

«DB_HOST»: «mongodb: //127.0.0.1:27017»,

«DB_NAME»: «hapiAuthor»,

«DB_OPTS»: {

«useNewUrlParser»: true,

«useUnifiedTopology»: true,

«poolSize»: 1

}

}

Развитие, основанное на поведении

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

Я выберу лабораторную утилиту Hapi и их библиотеку утверждений BDD для тестирования кода по мере его написания:

npm i @hapi/lab @hapi/code —save-dev

Добавьте этот базовый каркас в test/Author.jsтестовый код. Я выберу стиль разработки, ориентированной на поведение (BDD), чтобы сделать его более плавным:

const Lab = require ('@hapi/lab’)

const { expect } = require ('@hapi/code’)

const { after, before, describe, it } = exports.lab = Lab.script ()

const { init } = require ('.../server’)

const { connection } = require ('.../model’)

const id = '5ff8ea833609e90fc87fee52'

const payload = {

name: 'C R’,

email: 'xyz@abc.net’,

createdAt: '2021-01-08T06:00:00.000Z’

}

describe ('/v1/authors’, () => {

let server

before (async () => {

server = await init ()

})

after (async () => {

await server.stop ()

await connection.close ()

})

})

По мере создания большего количества моделей и конечных точек я рекомендую повторять один и тот же код каркаса для каждого тестового файла. Модульные тесты не являются СУХИМИ («не повторяйтесь»), и совершенно нормально запускать/останавливать соединение с сервером и базой данных. Соединение MongoDB и сервер Hapi могут справиться с этим, сохраняя при этом быстрые тесты.

Тесты почти готовы к запуску, за исключением небольшой морщинки в файле AuthorV1Controller1, потому что он пуст. Открой controllers/AuthorV1Controller.jsи добавь это:

exports.details = () => {}

exports.upsert = () => {}

exports.delete = () => {}

Тесты запускаются через npm tтерминал. Обязательно установите это в package.json:

«scripts»: {

«test»: «lab»

},

Идите вперед и запустите модульные тесты. Еще ничего не должно выйти из строя. Чтобы провалить модульные тесты, добавьте это внутри describe ():

it ('PUT responds with 201', async () => {

const { statusCode } = await server.inject ({

method: 'PUT’,

url: `/v1/authors/${id}`,

payload: {...payload}

})

expect (statusCode).to.equal (201)

})

it ('PUT responds with 200', async () => {

const { statusCode } = await server.inject ({

method: 'PUT’,

url: `/v1/authors/${id}`,

payload: {

...payload,

topics: ['JavaScript’, 'MongoDB’]}

})

expect (statusCode).to.equal (200)

})

it ('GET responds with 200', async () => {

const { statusCode } = await server.inject ({

method: 'GET’,

url: `/v1/authors/${id}`

})

expect (statusCode).to.equal (200)

})

it ('DELETE responds with 204', async () => {

const { statusCode } = await server.inject ({

method: 'DELETE’,

url: `/v1/authors/${id}`

})

expect (statusCode).to.equal (204)

})

Чтобы начать проходить модульные тесты, поместите это внутрь controllers/AuthorV1Controller.js:

const db = require ('.../model’)

exports.details = async (request, h) => {

const author = await db.Author.findById (request.params.id).exec ()

request.log (['implementation’], `GET 200 /v1/authors ${author}`)

return h.response (author.toObject ())

}

exports.upsert = async (request, h) => {

const author = await db.Author.findById (request.params.id).exec ()

if (! author) {

const newAuthor = new db.Author (request.payload)

newAuthor. _id = request.params.id

await newAuthor.save ()

request.log (['implementation’], `PUT 201 /v1/authors ${newAuthor}`)

return h

.response (newAuthor.toObject ())

.created (`/v1/authors/${request.params.id}`)

}

author.name = request.payload.name

author.email = request.payload.email

author.topics = request.payload.topics

request.log (['implementation’], `PUT 200 /v1/authors ${author}`)

await author.save ()

return h.response (author.toObject ())

}

exports.delete = async (request, h) => {

await db.Author.findByIdAndDelete (request.params.id)

request.log (

['implementation’],

`DELETE 204 /v1/authors ${request.params.id}`)

return h.response ().code (204)

}

Здесь следует отметить несколько вещей. Метод exec () материализует запрос и возвращает документ Mongoose. Поскольку в этом документе есть дополнительные поля, которые не нужны серверу Hapi, примените a toObjectперед вызовом response (). Код состояния API по умолчанию — 200, но его можно изменить с помощью code () или created ().

При красно-зелено-рефакторинговой разработке, основанной на тестах, я написал только минимальное количество кода, чтобы пройти тесты. Я оставлю вам написание дополнительных модульных тестов и других вариантов использования. Например, GET и DELETE должны возвращать 404 (не найдено), когда для целевого ресурса нет автора.

Hapi поддерживает и другие тонкости, такие как регистратор внутри requestобъекта. По умолчанию implementationтег отправляет журналы отладки на консоль, когда сервер запущен, и это также работает с модульными тестами. Это хороший чистый способ увидеть, что происходит с запросом, когда он проходит через конвейер запросов.

Тестирование

Наконец, прежде чем мы сможем запустить основной сервер, вставьте это index.js:

const { start } = require ('. /server’)

start ()

npm startВы должны получить работающий и работающий REST API в Hapi. Теперь я буду использовать Hoppscotch для отправки запросов на все конечные точки. Все, что вам нужно сделать, это нажать на ссылки ниже, чтобы протестировать API. Обязательно переходите по ссылкам сверху вниз:

ПУТ 201 /v1/авторы

PUT 200 /v1/авторы

ПОЛУЧИТЬ 200 /v1/авторы

УДАЛИТЬ 204 /v1/авторы

Или то же самое можно сделать в cURL:

curl -i -X PUT -H «Content-Type: application/json» -d «{\»name\»: \«C R\», \«email\»: \«xyz@abc.net\», \«createdAt\»: \«2021-01-08T06:00:00.000Z\"}» http: //localhost:3000/v1/authors/5ff8ea833609e90fc87fee52

201 Created {«name»:«C R»,«email»:«xyz@abc.net»,«createdAt»:"2021-01-08T06:00:00.000Z"}

curl -i -X PUT -H «Content-Type: application/json» -d «{\»name\»: \«C R\», \«email\»: \«xyz@abc.net\», \«createdAt\»: \«2021-01-08T06:00:00.000Z\», \«topics\»:[\«JavaScript\», \«MongoDB\"]}» http: //localhost:3000/v1/authors/5ff8ea833609e90fc87fee52

200 OK {«topics»:[«JavaScript»,«MongoDB"],"name»:«C R»,«email»:«xyz@abc.net»,«createdAt»:"2021-01-08T06:00:00.000Z"}

curl -i -H «Content-Type: application/json» http: //localhost:3000/v1/authors/5ff8ea833609e90fc87fee52

200 OK {«topics»:[«JavaScript»,«MongoDB"],"name»:«C R»,«email»:«xyz@abc.net»,«createdAt»:"2021-01-08T06:00:00.000Z"}

curl -i -X DELETE -H «Content-Type: application/json» http: //localhost:3000/v1/authors/5ff8ea833609e90fc87fee52

204 No Content

В Jamstack клиент JavaScript может выполнять эти вызовы через файл fetch (). Преимущество REST API в том, что он вообще не обязательно должен быть браузером, потому что подойдет любой клиент, поддерживающий HTTP. Это идеально подходит для распределенной системы, где несколько клиентов могут вызывать API через HTTP. API может оставаться автономным с собственным графиком развертывания и свободно развиваться.

Вывод

У JamStack есть хороший способ разделения программных модулей с помощью конечных точек с версиями и проверки модели. Сервер Hapi поддерживает эту и другие тонкости, такие как объявления типов, чтобы сделать вашу работу более приятной.

3D-печать5GABC-анализAndroidAppleAppStoreAsusCall-центрChatGPTCRMDellDNSDrupalExcelFacebookFMCGGoogleHuaweiInstagramiPhoneLinkedInLinuxMagentoMicrosoftNvidiaOpenCartPlayStationPOS материалPPC-специалистRuTubeSamsungSEO-услугиSMMSnapchatSonyStarlinkTikTokTwitterUbuntuUp-saleViasatVPNWhatsAppWindowsWordPressXiaomiYouTubeZoomАвдеевкаАктивные продажиАкцияАлександровск ЛНРАлмазнаяАлчевскАмвросиевкаАнализ конкурентовАнализ продажАнтимерчандайзингАнтрацитАртемовскАртемовск ЛНРАссортиментная политикаБелгородБелицкоеБелозерскоеБердянскБизнес-идеи (стартапы)БрендБрянкаБукингВахрушевоВендорВидеоВикипедияВирусная рекламаВирусный маркетингВладивостокВнутренние продажиВнутренний маркетингВолгоградВолновахаВоронежГорловкаГорнякГорскоеДебальцевоДебиторкаДебиторская задолженностьДезинтермедитацияДзержинскДивизионная система управленияДизайнДимитровДирект-маркетингДисконтДистрибьюторДистрибьюцияДобропольеДокучаевскДоменДружковкаЕкатеринбургЕнакиевоЖдановкаЗапорожьеЗимогорьеЗолотоеЗоринскЗугрэсИжевскИловайскИрминоКазаньКалининградКировскКировскоеКомсомольскоеКонстантиновкаКонтент-маркетингКонтент-планКопирайтингКраматорскКрасноармейскКрасногоровкаКраснодарКраснодонКраснопартизанскКрасный ЛиманКрасный ЛучКременнаяКураховоКурскЛисичанскЛуганскЛутугиноМакеевкаМариупольМаркетингМаркетинговая информацияМаркетинговые исследованияМаркетинговый каналМаркетинг услугМаркетологМарьинкаМедиаМелекиноМелитопольМенеджментМерчандайзерМерчандайзингМиусинскМолодогвардейскМоскваМоспиноНижний НовгородНиколаевНиколаевкаНишевой маркетингНовоазовскНовогродовкаНоводружескНовосибирскНумерическая дистрибьюцияОдессаОмскОтдел маркетингаПартизанский маркетингПервомайскПеревальскПетровскоеПлата за кликПоисковая оптимизацияПопаснаяПравило ПаретоПривольеПрогнозирование продажПродвижение сайтов в ДонецкеПроизводство видеоПромоПромоушнПрямой маркетингРабота для маркетологаРабота для студентаРазработка приложенийРаспродажаРегиональные продажиРекламаРеклама на асфальтеРемаркетингРетро-бонусРибейтРитейлРовенькиРодинскоеРостов-на-ДонуРубежноеСамараСанкт-ПетербургСаратовСватовоСвердловскСветлодарскСвятогорскСевастопольСеверодонецкСеверскСедовоСейлз промоушнСелидовоСимферопольСинергияСколковоСлавянскСнежноеСоздание сайтов в ДонецкеСоледарСоциальные сетиСочиСтаробельскСтаробешевоСтахановСтимулирование сбытаСуходольскСчастьеТелемаркетингТельмановоТираспольТорговый представительТорезТрейд маркетингТрейд промоушнТюменьУглегорскУгледарУкраинскХабаровскХарцызскХерсонХостингЦелевая аудиторияЦифровой маркетингЧасов ЯрЧелябинскШахтерскЮжно-СахалинскЮнокоммунаровскЯндексЯсиноватая