Table of Contents
Instalação do Apigility
Instalar via Composer
O caminho path/to/install
refere-se ao diretório onde o apigility será instalado.
$ composer create-project zfcampus/zf-apigility-skeleton path/to/install
Criar Virtual Host no Apache
Modifique de acordo com o seu ambiente de desenvolvimento:
<VirtualHost apigility.local:80> <Directory "path/to/install/public"> Options FollowSymLinks Indexes AllowOverride All Order deny,allow allow from All </Directory> ServerName apigility.local ServerAlias apigility.local DocumentRoot "path/to/install/public" ErrorLog "path/to/apache/logs/apigility.local.err" CustomLog "path/to/apache/logs/apigility.local.log" combined </VirtualHost>
Habilitar Ambiente de Desenvolvimento
Execute o comando do Composer para habilitar o ambiente de desenvolvimento:
$ composer development-enable
Criar a Primeira API
Acesse o Apigility no navegador (conforme configuração do hosts feita acima, ex.: http://apigility.local/
).
Clique no botão New API
no menu à esquerda, crie um nome para sua API e clique em Create
.
Atenção: O nome da API será usado para criar o
namespace
do módulo no Zend Framework, então deve-se seguir a convenção de se utilizarPascalCase
.
Instalação do Doctrine
Instalar Plugin e Módulo
Execute o comando abaixo para instalar o plugin do Doctrine para Apigility e o DoctrineORMModule para o Zend Framework:
$ composer require zfcampus/zf-apigility-doctrine && composer require doctrine/doctrine-orm-module
Durante a instalação, será solicitado escolher em qual arquivo serão armazenadas as configurações dos módulos instalados. Responda conforme os valores informados abaixo:
Please select which config file you wish to inject 'Zend\Form' into: [0] Do not inject [1] config/modules.config.php [2] config/development.config.php.dist Make your selection (default is 0): 1 # Informe 1 e tecle ENTER Remember this option for other packages of the same type? (y/N) y # Informe y e tecle ENTER Please select which config file you wish to inject 'DoctrineModule' into: [0] Do not inject [1] config/modules.config.php [2] config/development.config.php.dist Make your selection (default is 0): 1 # Informe 1 e tecle ENTER # Após concluir a instalação do plugin do Doctrine para Apigility, será iniciada a instalação do DoctrineORMModule Please select which config file you wish to inject 'DoctrineORMModule' into: [0] Do not inject [1] config/modules.config.php [2] config/development.config.php.dist Make your selection (default is 0): 1 # Informe 1 e tecle ENTER Remember this option for other packages of the same type? (y/N) y # Informe y e tecle ENTER
Ajustar Instalação
Após a instalação do plugin do Doctrine e do DoctrineORMModule, será necessário fazer um ajuste nos arquivos de configuração, conforme abaixo:
// config/modules.config.php <?php return [ // ... 'DoctrineModule', 'Phpro\DoctrineHydrationModule', 'ZF\Apigility\Doctrine\Admin', // Remova esta linha 'ZF\Apigility\Doctrine\Server', 'DoctrineORMModule', 'Application', // ... ]; // config/development.config.php <?php return [ // Development time modules 'modules' => [ 'ZendDeveloperTools', 'ZF\Apigility\Admin', 'ZF\Apigility\Doctrine\Admin', // Insira esta linha aqui ], // ... ];
ATENÇÃO: a ordem é importante, uma vez que há dependência entre os módulos. Portanto, o módulo
Doctrine\Admin
deve ser carregado após o móduloAdmin
. Além disso, no arquivomodules.config.php
, não mude a ordem os módulo que lá estão, uma vez que também há dependência entre eles.
Configurar Conexão com o Banco
Crie um arquivo de configuração local do Doctrine:
// config/autoload/doctrine.local.php <?php return [ 'doctrine' => [ 'connection' => [ 'orm_default' => [ 'driverClass' => 'Doctrine\DBAL\Driver\PDOMySql\Driver', 'params' => [ 'host' => 'localhost', 'port' => '3306', 'user' => 'root', 'password' => '', 'dbname' => 'apigility', 'charset' => 'utf8', 'driverOptions' => ['SET NAMES utf8'] ] ] ] ] ];
Configurar Doctrine no Módulo da API
No arquivo module.config.php
, insira o seguinte elemento no array de retorno:
Substitua
Super
pelo nome da sua API
// module/Super/config/module.config.php <?php return [ 'doctrine' => [ 'driver' => [ 'Super_driver' => [ 'class' => \Doctrine\ORM\Mapping\Driver\AnnotationDriver::class, 'cache' => 'array', 'paths' => [ './module/Super/src/V1/Entity', ], ], 'orm_default' => [ 'drivers' => [ 'Super' => 'Super_driver', ], ], ], ], // ... ];
Verificar Instalação
Acesse o Apigility no navegador e verifique se o Doctrine está corretamente instalado:
Se houver algum erro, por favor verifique se você está usando uma versão maior ou igual ao PHP 7.1. Enfrentei o seguinte problema, porque esta usando o PHP 7.0:
PHP Fatal error: Uncaught TypeError: Return value of Doctrine\Common\Annotations\AnnotationRegistry::registerLoader() must be an instance of Doctrine\Common\Annotations\void, none returned in /Applications/MAMP/htdocs/apigility/vendor/doctrine/annotations/lib/Doctrine/Common/Annotations/AnnotationRegistry.php:117
Stack trace:
#0 /Applications/MAMP/htdocs/apigility/vendor/doctrine/doctrine-module/src/DoctrineModule/Module.php(57): Doctrine\Common\Annotations\AnnotationRegistry::registerLoader(Object(Closure))
#1 /Applications/MAMP/htdocs/apigility/vendor/zendframework/zend-modulemanager/src/Listener/InitTrigger.php(33): DoctrineModule\Module->init(Object(Zend\ModuleManager\ModuleManager))
#2 /Applications/MAMP/htdocs/apigility/vendor/zendframework/zend-eventmanager/src/EventManager.php(271): Zend\ModuleManager\Listener\InitTrigger->__invoke(Object(Zend\ModuleManager\ModuleEvent))
#3 /Applications/MAMP/htdocs/apigility/vendor/zendframework/zend-eventmanager/src/EventManager.php(143): Zend\EventManager\EventManager->triggerListe in /Applications/MAMP/htdocs/apigility/vendor/doctrine/annotations/lib/Doctrine/Common/Annotations/AnnotationRegistry.php on line 117
Este erro ocorre porque a versão mais recente do Doctrine\Common\Annotations
usa o PHP 7.1. Essa é a razão dele usar void como um tipo de retorno, e isso não é suportado no PHP 7.0.
Instalação do Plugin para CORS
Instalar o Plugin
Execute o comando abaixo para instalar o plugin de configuração do CORS:
$ composer require zfr/zfr-cors:1.*
Novamente será solicitado escolher em qual arquivo serão armazenadas as configurações. Informe os valores conforme abaixo:
Please select which config file you wish to inject 'ZfrCors' into: [0] Do not inject [1] config/modules.config.php [2] config/development.config.php.dist Make your selection (default is 0): 1 # Informe 1 e tecle ENTER Remember this option for other packages of the same type? (y/N) y # Informe y e tecle ENTER
Configurar o CORS
Copie o arquivo de exemplo de configuração do diretório de instalação do plugin para o diretório dos arquivos de autoload e renomeie-o removendo a extensão .dist
. O comando abaixo fará tudo isso:
$ cp ./vendor/zfr/zfr-cors/config/zfr_cors.global.php.dist ./config/autoload/zfr_cors.global.php
Modifique o arquivo de configuração do CORS conforme o exemplo abaixo, descomentando todos os parâmetros de configuração no array (os comentários originais foram removidos para melhor visualização):
// config/autoload/zfr_cors.global.php <?php return [ 'zfr_cors' => [ // Insira aqui os domínios que poderão fazer requisições à API e os métodos que poderão ser utilizados 'allowed_origins' => ['http://example.com'], 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Insira os cabeçalhos permitidos nas requisições conforme abaixo 'allowed_headers' => ['Authorization', 'Content-Type', 'Access-Control-Allow-Origin'], // Outras configurações 'max_age' => 120, 'exposed_headers' => [], 'allowed_credentials' => false, ], ];
Habilitar Extensões do Chrome
Algumas requisições à API (como ao endpoint /oauth
que será criado a seguir) não são compatíveis com plugins do chrome como Postman ou ARC e, ao tentar fazer o request, o seguinte erro será exibido na tela:
Fatal error: Uncaught Zend\Uri\Exception\InvalidArgumentException: no class registered for scheme “chrome-extension” in C:\ampps\www\apigility\vendor\zendframework\zend-uri\src\UriFactory.php on line 104
Para corrigir isso, altere o arquivo Module.php
conforme abaixo:
// module/Super/Module.php <?php //... use Zend\Uri\UriFactory; // Insira esta linha class Module implements ApigilityProviderInterface { // ... // Insira o método abaixo public function onBootstrap() { UriFactory::registerScheme('chrome-extension', 'Zend\Uri\Uri'); } }
Após fazer as alterações acima, será possível fazer requisições através dos plugins do Chrome como Postman e ARC. Porém, como as requisições feitas por esses plugins partem de uma origem diferente da sua aplicação, a API retornará o seguinte erro:
# Erro retornado no Postman Status: 403 The origin "chrome-extension://fhbjgbiflinjbdggehcddcbncdddomop" is not authorized
O erro acima ocorre porque o Postman não foi adicionado à lista de origens permitidas para CORS no Apigility. Para corrigir isso, basta inserir a origem no array de configuração do CORS:
// config/autoload/zfr_cors.global.php <?php return [ 'zfr_cors' => [ // Insira o endereço de origem no array abaixo 'allowed_origins' => ['http://example.com', 'chrome-extension://fhbjgbiflinjbdggehcddcbncdddomop'], // ... ]; ];
Nota: ao criar uma aplicação cliente, é necessário adicionar sua url na lista de origens permitidas. A exemplo, se uma aplicação cliente estiver no endereço
frontend.local
e a api estiver no endereçobackend.local
, as requisições não serão aceitas a não ser que se adicione o endereçofrontend.local
à lista de origens permitidas.
Configuração do OAuth2
Criar Tabelas no Banco
Importe o arquivo vendor/zfcampus/zf-oauth2/data/db_oauth2.sql
para dentro do banco de dados. As seguintes tabelas serão criadas:
oauth_access_tokens oauth_authorization_codes oauth_clients oauth_jwt oauth_refresh_tokens oauth_scopes oauth_users
Essas tabelas serão utilizadas pelo Apigility para fazer a autenticação e autorização dos usuários e seus respectivos clientes durante o acesso à API.
Nota: É importante notar que o Apigility usará a tabela
oauth_users
para obter os dados dos usuários, como login e senha. Leve em conta que configurar o Apigility para utilizar outra tabela que não seja a padrão (oauth_users
) pode-se tornar cansativo e gerar muitos erros inesperados. Portanto, caso o banco de dados já tenha uma tabela previamente criada para armazenamento dos usuários, recomenda-se ajustar o modelo para passar a utilizar a tabelaoauth_users
para esse fim.
Criar Adapter
Acesse o Apigility no navegador e crie um Authentication Adapter
para o OAuth2:
Preencha os campos conforme o exemplo abaixo e clique em Save
:
Cadastrar Clientes da API
A fim de se fazer as requisições, o OAuth2 precisará receber os dados de acesso do cliente que estará fazendo a solicitação. Para tanto, é necessário que seja feito o cadastro dos clientes que usarão a API diretamente no Banco de Dados.
Cadastre um novo cliente na tabela oauth_clients
conforme exemplo abaixo:
-- Cria um novo cliente com os dados abaixo -- client_id: testclient -- client_secret: testpass INSERT INTO oauth_clients (client_id, client_secret, redirect_uri) VALUES ('testclient', '$2y$10$5ICo6mbnWLsptjCZVfMu1e7p04FYpgiZydEG1KD4MI8Q2fcwuCu8e', '/oauth/receivecode');
O exemplo acima criará um cliente chamado testclient com a senha testpass. O campo client_secret
armazena senhas com hash utilizando bcrypt. Para gerar uma senha, utilize o seguinte comando:
# Gera hash da senha 123456 php vendor/zfcampus/zf-oauth2/bin/bcrypt.php 123456 # Output $2y$10$FnkngtFrGeu1DPtasu68euDpJksCU3o092gIFV0H.N0WW2YTB88.K
Clientes com senha são chamados de clientes confidenciais (mais detalhes). Caso a API venha a oferecer acesso a clientes públicos, basta inserir o registro do novo cliente no banco sem inserir um client_secret
. O Apigility automaticamente tratará a requisição e idenficará que se trata de um cliente público e não confidencial.
Cadastrar Usuários
O cadastro de usuários será feito na aplicação. Porém, para finalidade de teste, pode-se fazer a inserção de um novo usuário diretamente no banco de dados:
-- Cria um novo usuário com os dados abaixo -- username: johndoe -- password: 123456 INSERT INTO oauth_users (username, password, first_name, last_name) VALUES ('johndoe', '$2y$10$FnkngtFrGeu1DPtasu68euDpJksCU3o092gIFV0H.N0WW2YTB88.K', 'John', 'Doe');
Testar Autenticação
Atenção: O exemplo a seguir testará se a API está configurada corretamente para receber as requisições no endpoint
/oauth
e gerenciar o login utilizando OAuth2.Tenha em mente que esse é apenas um teste para verificar se tudo está configurado corretamente, e que o método apresentado abaixo não deve ser utilizado em produção, uma vez que ele faz a autenticação passando o
client_secret
no corpo da requisição.Para saber mais sobre como implementar os mecanismos de autenticação utilizando OAuth2 em diferentes contextos, acesse a documentação oficial.
Para testar se a autenticação está funcionando corretamente, faça a seguinte requisição ao endereço http://<apigility URL>/oauth
utilizando o método POST:
POST /oauth HTTP/1.1 Accept: application/json Content-Type: application/json { "username": "johndoe", "password": "123456", "grant_type": "password", "client_id": "testclient", "client_secret": "testpass" }
A requisição acima deverá retornar o token de acesso Bearer
:
{ "access_token": "403000e75765e89e7566472c945e265734f238b4", "expires_in": 3600, "token_type": "Bearer", "scope": null, "refresh_token": "34e9f4b1e5730424bf89cffdf69fd5d45693ea7d" }
Criação dos Serviços
Gerar Entidades com Doctrine
Antes de se criar quaisquer serviços, é preciso primeiro gerar as entidades utilizando o CLI do Doctrine e, para isso, é necessário fazer um ajuste na tabela oauth_scopes
. Caso contrário, o seguinte erro será exibido:
[Doctrine\ORM\Mapping\MappingException] Table oauth_scopes has no primary key. Doctrine does not support reverse en gineering from tables that don't have a primary key.
Isso acontece pois o Doctrine não consegue lidar com tabelas que não possuam uma chave primária (apesar de que tabelas de ligação com chaves compostas funcionam sem problemas). Para corrigir o problema, execute o comando abaixo no banco de dados:
-- Insere a coluna id no início da tabela ALTER TABLE `oauth_scopes` ADD COLUMN `id` INT NOT NULL AUTO_INCREMENT FIRST, ADD PRIMARY KEY (`id`);
Para gerar as entidades utilizando o CLI do Doctrine, utilize o comando abaixo substituindo o nome do módulo conforme necessário:
$ vendor/bin/doctrine-module orm:convert-mapping --force --from-database --namespace="Super\\V1\\Entity\\" annotation ./module/Super/src/
Nota: para gerar apenas uma entidade ao invés de todas, utilize o flag
--filter "Entidade"
.
O Doctrine criará os arquivos das entidades no diretório incorreto. Para corrigir isso, execute o comando abaixo, alterando o nome do módulo conforme necessário:
$ mv module/Super/src/Super/V1/Entity module/Super/src/V1/Entity && rm -rf module/Super/src/Super
O próximo passo é gerar os getters
e setters
das entidades. Para fazer isso, execute o comando abaixo:
$ vendor/bin/doctrine-module orm:generate-entities module/Super/src --generate-annotations=true
Nota: Novamente, caso deseje gerar os
getters
esetters
para apenas uma entidade, utilize a flag--filter "Entidade"
.
Mais uma vez o Doctrine criará os arquivos no diretório incorreto. Para resolver isso, execute o seguinte comando:
$ rm -rf module/Super/src/V1/Entity && mv module/Super/src/Super/V1/Entity module/Super/src/V1/Entity && rm -rf module/Super/src/Super
Atenção: Caso não precise utilizar as tabelas geradas pelo OAuth2 no banco de dados, é recomendável excluir os arquivos das entidades que foram gerados automaticamente pelo Doctrine. Em suma, exclua todos os arquivos cujo nome comece com
OAuth
, como por exemploOauthAccessTokens.php
eOauthScopes.php
.Vale observar que no início, ao configurar o banco, recomendou-se armazenar os usuários na tabela
oauth_users
. Portanto, ao excluir os arquivos das entidades não utilizadas, lembre-se de não excluir o arquivoOauthUsers.php
, uma vez que ele será utilizado em sua aplicação para manipular os usuários no banco de dados.Recomenda-se ainda renomear o arquivo e a classe de
OauthUsers.php
paraUser.php
a fim de simplificar o nome da classe para facilitar seu uso.
Criar os Serviços
A última etapa é criar os serviços conectados com Doctrine. Para fazer isso, acesse o Apigility no navegador e crie um novo serviço conforme as imagens abaixo:
Verifique se os serviços foram criados corretamente clicando no nome do novo serviço no menu à esquerda:
Testar os Serviços
Para testar os serviços, basta fazer uma requisição ao seu endpoint. O exemplo abaixo é um retorno à requisição GET
no endpoint http://<apigility URL>/product
:
{ "_links": { "self": { "href": "http://apigility.local/product" } }, "_embedded": { "product": [] }, "page_count": 0, "page_size": 25, "total_items": 0, "page": 0 }
IMPORTANTE: Ao gerar as entidades automaticamente, o Doctrine nomeia a variável que mapeia para a chave primária da tabela conforme o nome da coluna, converte-o para
camelCase
. Ex.: se uma tabala possui comoPK
a colunacod_produto
, o Doctrine criará a variável$codProduto
para mapear essa propriedade para a coluna em questão. Essa variável é chamada de Entity Identifier.Quando se cria um serviço conectado ao Doctrine, o nome da variável utilizada como identificador da entidade é
id
por padrão. Isso ocorre independentemente de como a entidade está configurada. Portanto, no exemplo acima em que o identificador da entidade se chama$codProduto
, ao se tentar fazer uma requisição passando a ID do produto = 4, o Apigility solicitará ao Doctrine para encontrar a entidade cujoid
seja igual a 4, retornando assim um erro. O erro ocorrerá pois o Doctrine não encontrará a propriedadeid
na entidade (ele esperaria receber o valor para a variávelcodProduto
e nãoid
).Para resolver esse problema, há dois caminhos: o primeiro (e mais fácil) é alterar o nome da variável na entidade simplesmente para
$id
. Assim, não é preciso fazer nenhuma mudança nas configurações do Apigility.A segunda opção é alterar o valor do campo Entity identifier name nas configurações do serviço (em General Settings) no painel do Apigility no navegador. O problema é que existe um bug que ignora essa mudança, mesmo após seu salvamento (ele não persiste o valor no arquivo, como seria esperado). Portanto, para contornar isso, o correto é ir diretamente no arquivo
module/<Namespace>gt;/src/config/module.config.php
e fazer as devidas modificações manualmente.
Bloquear Acesso a Rotas
É possível restringir uma rota para que apenas usuários autenticados e autorizados possam acessá-la. Para isso, clique no serviço desejado no menu à esquerda e em seguida selecione a aba Authorization. Escolha os métodos HTTP que deverão ser bloqueados. Apenas como exemplo, vamos bloquear o acesso ao endpoint /product
com o verbo GET
para uma entidade.
Nota: somente as rotas habilitadas nas configurações do serviço estarão disponíveis. Para habilitar/desabilitar rotas para o serviço, acesse a aba General Settings e marque ou desmarque as caixas de seleção em HTTP Entity Methods e HTTP Collection Methods.
Para testar se uma rota está restrita apenas a usuários autorizados, cadastre um registro na tabela product
e, em seguida, faça uma requisição para /product/[id]
, e o seguinte retorno será esperado:
{ "type": "http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html", "title": "Forbidden", "status": 403, "detail": "Forbidden" }
Isso significa que a rota está restrita. Para poder acessá-la, solicite um token de acesso conforme [explicado acima](#Testar Autenticação).
Ao obter o token, basta fazer uma requisição à rota passando o código Bearer
como valor do parâmetro Authorization
no cabeçalho da requisição, desta forma:
GET /product/1 HTTP/1.1 Host: apigility.local Authorization: Bearer 403000e75765e89e7566472c945e265734f238b4
O seguinte resultado será esperado:
{ "id": 1, "name": "My product", "price": "5.00", "inStock": true, "_links": { "self": { "href": "http://apigility.local/product/1" } } }
Deploy para Produção
Gerar o Package
Para gerar o package para produção, basta acessar o Apigility no navegador e ajuster as configurações do pacote na aba Package conforme a imagem abaixo:
O arquivo final será gerado e o download será iniciado.
jhonatas felipe
November 6, 2018Tudo bom ! estou tentando fazer colocar o apigility em produção mas sempre que subo ele para o servido ele me mostra um erro na hora de fazer requisições, mas a documentação está funcionarndo normal
JEFERSON
April 24, 2019Estou com dificuldade para instalar o doctrine.
Problem 1
– Installation request for zfcampus/zf-apigility-doctrine ^2.3 -> satisfiable by zfcampus/zf-apigility-doctrine[2.3.0].
– Conclusion: remove zendframework/zend-hydrator 3.0.2
– Conclusion: don’t install zendframework/zend-hydrator 3.0.2
– zfcampus/zf-apigility-doctrine 2.3.0 requires phpro/zf-doctrine-hydration-module ^2.0.1 || ^3.0 || ^4.1 -> satisfiable by phpro/zf-doctrine-hydration-module[v2.0.1, v3.0.0, v4.1.0].
– phpro/zf-doctrine-hydration-module v3.0.0 requires zendframework/zend-hydrator ^1.1 || ^2.2.1 -> satisfiable by zendframework/zend-hydrator[1.1.0, 2.2.1, 2.2.2, 2.2.3, 2.3.0, 2.3.1, 2.4.0, 2.4.1].
– phpro/zf-doctrine-hydration-module v4.1.0 requires zendframework/zend-hydrator ^1.1 || ^2.2.1 -> satisfiable by zendframework/zend-hydrator[1.1.0, 2.2.1, 2.2.2, 2.2.3, 2.3.0, 2.3.1, 2.4.0, 2.4.1].
– phpro/zf-doctrine-hydration-module v2.0.1 requires zendframework/zend-hydrator ~1.0 || ^2.0 -> satisfiable by zendframework/zend-hydrator[1.0.0, 1.1.0, 2.0.0, 2.1.0, 2.2.0, 2.2.1, 2.2.2, 2.2.3, 2.3.0, 2.3.1, 2.4.0, 2.4.1].
– Can only install one of: zendframework/zend-hydrator[1.1.0, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[1.0.0, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.1.0, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.2.0, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.2.1, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.2.2, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.2.3, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.3.0, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.3.1, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.4.0, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.4.1, 3.0.2].
– Can only install one of: zendframework/zend-hydrator[2.0.0, 3.0.2].
– Installation request for zendframework/zend-hydrator (locked at 3.0.2) -> satisfiable by zendframework/zend-hydrator[3.0.2].
Installation failed, reverting ./composer.json to its original content.
Harry
July 2, 2019Hi, thanks for this amazing tutorial! Best on the web tbh (even though with the Google Translate :D) – Just one thing is not clear to me. At what step did your Doctrine generate the Product entity? The one that is listed when you are creating the service. Mine just shows the Oauth entities.
тонировка дубай
September 15, 2021Just desire to sayy your article iss as amazing. The clearness in your
post is just grdeat and i ccan assume you’re an expert on this subject.
Fine with your permission let me to grab your
RSS feed to keep updated with forthcoming post.
Thanks a millikn and please continue the rewarding work.
тонировка дубай
Ekskurzia Dubai
екскурзия дубай