Часть первая: Google Cloud Endpoints на Java: Руководство. ч. 1
Часть третья: Google Cloud Endpoints на Java: Руководство. ч. 3

В первой части мы рассмотрели создание проекта на Google Cloud Endpoints с Java, в этой статье речь пойдет о создании фронтенда к нашему API.

В дополнение к инструментам, использовавшимся в первой части, нам понадобится:

AngularJS, и начальное общее представление о том как он работает, опционально Bootstrap или Foundation.

Простейший веб-сервер на локальной машине для тестирования, и сервер для деплоя приложения.

Локальный сервер


Приложение на AngularJS состоит из обычного набора «статических» файлов: .html, .js, .css + файлы изображений, шрифты и т.п. Казалось бы, поскольку это как правило одностраничное приложение, можно просто открыть index.html в браузере. Однако при попытке просто открыть index.html в Google Chrome мы можем увидеть страницу без основного содержания и в консоли нечто вроде этого:

image

В Firefox то же приложение может работать без веб-сервера, но ссылки вида src="//… или href="… превращаются в file:///…
Ну и Chrome DevTools, как мой вкус гораздо удобнее.

Поэтому использовать веб-сервер на локальной машине будет все же предпочтительнее. Можно использовать Sinatra на Ruby, NodeJS, старый добрый Apache HTTP Server («httpd»). App Engine Java SDK также включает development web server запускаемый на локальной машине, и имитирующий сервисы сервиса GAE включая базу данных (datastore).
В IntelliJ IDEA при редактировании .html файла высвечиваются иконки браузеров, если кликнуть то файл откроется на локальном веб-сервере запущенном IntelliJ IDEA.

На мой взгляд, самым простым и универсальным вариантом является Python (тем более, что он у Вас скорее всего уже установлен):
python -m SimpleHTTPServer <port>

в данном случае «python» означает запуск Python версии 2.x (по умолчанию), "-m" — запуск модуля, "SimpleHTTPServer" — собственно модуль представляющий собой простейший http-сервер и входящий в стандартную установку (т.е. если у Вас установлен Python, то этот модуль уже присутствует), опционально — номер порта, если этот параметр не указывать, то по умолчанию будет 8000.
Эту команду надо исполнить в директории проекта, там где находится наш index.htm, который и будет отдаваться по умолчанию в / на веб-сервере, и на localhost:8000/ (http://127.0.0.1:8000/) получаем статический http-сервер. Удобно тем, что никаких настроек в самой директории, и вообще больше никаких настроек не нужно.

Деплой


Разместить фронт-энд веб-приложение для нашего API можно на любом веб-сервере на котором можно размещать статичные файлы, например на бесплатном GitHub Pages (см. также Setting up a custom domain with GitHub Pages) или на Amazon Web Services (AWS).

Естественно логичным решением является и размещение фронтенда на GAE. Это может быть в одном проекте. В нашем руководстве, в учебных целях, мы разместим бэкенд и фронт-энд в разных проектах на GAE. В предыдущей части мы создали для этого два проекта в консоли разработчика. Фронтенд будет жить проекте в hello-habrahabr-webapp, а бэкенд в hello-habrahabr-api.

Домен


В дополнение к имеющемуся доменному имени вида {проект ID}.appspot.com, мы можем использовать собственный домен зарегистрированный как через Google Domains, так и у другого регистратора, или несколько доменов для одного проекта. Ранее эта возможность была доступна только для доменов используемых в Google Apps, но с недавнего времени привязка к Google Apps убрана.

Для того чтобы добавить домен, выбираем проект в консоли разработчика и переходим в меню: Compute -> App Engine -> Settings -> Custom domains. Затем выбираем 'Register a new domain' чтобы зарегистрировать домен у Google, или если у нас уже есть домен 'Add a custom domain'

image

Если мы добавляем домен зарегистрированный не у Google, то сначала надо пройти проверку что вы собственник домена и добавить домен в список проверенных на для вашей учетной записи google: вписываем доменное имя в поле обозначенное цифрой 1, нажимаем 'Verify' и переходим на страницу верификации:

image

если домен зарегистрирован у godaddy.com или другого крупного до для верификации нужно выбрать провайдера и и открывшемся окне залогиниться:

image

и подтвердить:

image

Либо сделать верификацию вручную, инструкции будут показаны по клику на 'Add a TXT record.'

На странице:

image

можно добавить «свойства» верифицированных доменов, например добавить дополнительную учетную запись google с которой можно управлять доменом в сервисах Google.

После верификации домена, в разделе обозначенном цифрой 2. (если мы только что добавили новый домен, то нужно перегрузить страницу, чтобы новый домен стал доступен для выбора) мы можем присвоить этот домен текущему проекту, либо «целиком», либо создать и присвоить субдомен, либо и то и другое.

image

После выбора домена и нажатия кнопки 'Add', следует внести изменения в настройки домена у регистратора (DNS Zone File) согласно инструкциям указанным в разделе под цифрой 3:

image

Внимание: после нажатия кнопки 'Add' визуально ничего не отражается, надо перейти в другой раздел (например 'Application settings'), и снова вернуться в 'Custom domains' — теперь там должны быть показаны добавленные (суб)домены и их настройки.

Теперь проект будет (не сразу, а в течении времени до 24 часов) доступен как по адресу {проект ID}.appspot.com, так и по добавленным адресам (доменам).

Настройка SSL


Доступ к адресу {проект ID}.appspot.com может осуществляться как по http так и https
Для того чтобы обеспечить доступ по https к собственному домену, нам потребуется SSL-сертификат подписанный удостоверяющий центром (англ. Certification authority, CA) подпись которого принимается браузерами.
Ведущими провайдерами услуг удостоверяющего центра в настоящее время являются Symantec с брендами GeoTrust, Thawte, Verisign, тот же Godaddy, и Comodo. Наибольшая доля рынка, и более низкие цены у Comodo. Если приобретать сертификаты через реселлеров цена обычно еще ниже.

Для начала/обучения можно воспользоваться бесплатным сертификатом от Comodo на 90 дней (бесплатно выдается один раз на домен) и/или аналогичным предложением на 30 дней от RapidSSL.

Для того чтобы получить сертификат нужно направить провайдеру certificate signing request (CSR) (запрос на подпись сертификата). Некоторые сервисы предлагают генерацию сертификата на сайте, однако в таком случае нет гарантии что секретный ключ будет только у вас, поэтому CSR лучше сгенерировать самостоятельно. Для этого воспользуемся утилитой OpenSSL.

Сначала создадим файл конфигурации для CSR ( назовем его scr.conf ):
# csr.conf
# you can rename scr.conf, scr.key, scr.csr to filenames you prefer,
# but with the same filename extensions
[req]
default_bits       = 2048 #
default_md         = sha512
default_keyfile    = csr.key #name of keyfile
distinguished_name = req_distinguished_name
prompt             = yes
# If 'prompt = no' provide right values to properties, not to _default properties
# and remove or comment _default properties.
# Use 'yes' and _default to see and correct values interactively
encrypt_key        = no #
# req_extensions = v3_req           #this is for multi-domain certificate

[req_distinguished_name]
#
# Use your company name, e-mai, domain names as default values
# if you enter '.', the field will be left blank
#
countryName = Country Name (2 letter code)
countryName_default = GB
stateOrProvinceName = State or Province Name (full name)
stateOrProvinceName_default = City of London
localityName = Locality Name (eg, city)
localityName_default = London
organizationName = Organization name
organizationName_default = MyCompany Limited
organizationalUnitName = Organizational Unit Name (eg, section)
organizationalUnitName_default = .
commonName = This is fully qualified domain name that you wish to secure
# e.g. www.example.com or mail.example.com
commonName_default = www.my-domain.com
emailAddress = Email Address
emailAddress_default = admin@my-domain.com

# [v3_req]
# subjectAltName = @alt_names       #this is for multi-domain certificate

# [alt_names]                       #this is for multi-domain certificate
# DNS.1   = my-domain.net           #this is for multi-domain certificate
# DNS.2   = my-domain.org           #this is for multi-domain certificate
# DNS.3   = myseconddomain.com      #this is for multi-domain certificate

# to generate .csr:
# openssl req -newkey rsa:2048 -sha512 -out csr.csr -config csr.conf
# or:
# openssl req -newkey rsa:2048 -sha512 -nodes -out csr.csr -config csr.conf
# Note:  If the "-nodes" is entered the key will NOT be encrypted with a
# DES pass phrase, ( see:
# https://support.comodo.com/index.php?/Default/Knowledgebase/Article/View/1/19/csr-generation-using-openssl-apache-wmod_ssl-nginx-os-x
# )
#
# to verify .csr:
# openssl req -text -noout -verify -in csr.csr
#
# in one command generate and verify:
# openssl req -newkey rsa:2048 -out csr.csr -config csr.conf && openssl req -text -noout -verify -in csr.csr
#



теперь запустим команду:

openssl req -newkey rsa:2048 -out csr.csr -config csr.conf


если в настройках мы указали 'prompt = yes', то у нас есть возможность просмотреть вводимые значения и изменять их. Если не нужно менять значения указанные по умолчанию, то просто жмем 'Enter'

В результате мы создадим два новых файла: csr.key — это приватный ключ, и csr.csr — это CSR (запрос на подпись сертификата)

проверить CSR можно командой:
openssl req -text -noout -verify -in csr.csr

Файл .csr нужно открыть текстовым редактором, внутри будет нечто вроде:
-----BEGIN CERTIFICATE REQUEST-----
MIIC5TCCAc0CAQAwgZ8xCzAJBgNVBAYTAkdCMRcwFQYDVQQIDA5DaXR5IG9mIExv
bmRvbjEPMA0GA1UEBwwGTG9uZG9uMRowGAYDVQQKDBFNeUNvbXBhbnkgTGltaXRl
NZBB4bDdgJ+uyNZq54dM1tUvzSolv/+LAY78/z85edqLH4nc5CxgMEn8hurFOpB4
RXS+ShhpBsJr6RJhSk2xkRe/idkM/TUon/7n1TUthFpjv2tYQZ6on3iWUZ61FDuM
mNPHGMIX+sn/OceViRtlu1Lx+t4JV9dTJQ==
-----END CERTIFICATE REQUEST-----

Содержание файла нужно будет скопировать, и вставить в форму на сайте, примерно такую:

image

Для простейшей проверки владения доменом у вас должен быть доступ к e-mail который выдает whois, либо к одному из email адресов в данном домене:
admin@
administrator@
postmaster@
hostmaster@
webmaster@

Либо нужно разместить http: //yourdomain.com/{Upper case value of MD5 hash of CSR}.txt или модифицировать DNS CNAME record указав опять же соответствующий хэш.

Расширенная проверка: Extended Business Verification или сокращенно EV, требует предоставления документов организации, на которую выдается сертификат, но позволяет получить сертификат с «зеленой строкой» (GreenBar) в браузере в которой отражается имя организации — владельца сертификата.

После прохождения проверки вы сможете сгрузить с сайта провайдера или получить по электронной почте файл вида {ваш domain или номер заказа}.crt, обычно в пакете (.zip) с сертификатами сертификационного центра, которые также имеют расширение .crt Этот файл .zip нужно разархивировать в отдельную директорию.

Наш сертификат мы можем проверить и просмотреть содержащуюся в нем информацию командой:
openssl x509 -in {имя файла}.crt -text -noout

где {имя файла}.crt — имя нашего файла сертификата.

Теперь для GAE нам нужно конвертировать наш приватный ключ из формата .key в формат .pem:
openssl rsa -in *.key > forGAE.key.pem


Внимание: в этой команде не нужно указывать параметр -text, как рекомендуют на сайте Google, так как эта команда добавляет в ключ ненужный и лишний в данном случае текст. Если при попытке загрузить ключ, вы получаете сообщение:
'The private key you've selected does not appear to be valid'
то скорее всего причина в этом. Откройте файл текстовым редактором и удалите все что выше строки -----BEGIN RSA PRIVATE KEY-----
Или переконвертируйте ключ еще раз, без параметра -text

2) соединить (сoncatenate) все .crt файлы из полученного .zip файла (и наш сертификат, и сертификаты сервера), командой такого вида:
cat mydomain_com.crt ASecureServerCA.crt ATrustCA.crt ATrustExternal.crt > concat.crt

Внимание: порядок файлов важен, (просто cat *.crt > concat.crt в данном случае не подойдет) первым должен идти наш полученный сертификат нашего домена, потом сертификаты сертификационного центра имена фалов которых будут отличаться, но будут похожими не те что в примере.

Теперь проверим полученные файлы:

openssl x509 -noout -modulus -in concat.crt | openssl md5

и
openssl rsa -noout -modulus -in forGAE.key.pem | openssl md5


должны выдавать одинаковое значение.

Теперь в меню консоли разработчика переходим Compute -> Settings -> SSL Sertificates -> Upload a new certificate

image

В поле 'Name' вводим имя, которое хотим дать нашему сертификату в консоли разработчика.
В 'PEM encoded X.509 public key certificate' загружаем наш concat.crt
В поле 'Unencrypted PEM encoded RSA private key' загружаем наш ключ в формате .pem (forGAE.key.pem)
Жмем 'Upload'

image

Отмечаем чекбоксы напротив доменных имен для которых мы можем (и хотим) активировать сертификат (это те которые указанны в самом сертификате, естественно), и нажимаем синюю кнопку 'Save':

image

Теперь в адресной строке браузера наберем имя нашего домена указав в начале протокол httpS://

image

На сайте пока ничего нет, но в начале адресной строки мы увидим зеленый замочек, кликнув по нему можем просмотреть данные сертификата.
SSL работает.

Скелет приложения


Также как в первой части используем Maven:
mvn archetype:generate -Dappengine-version=1.9.28 -Dapplication-id=hello-habrahabr-webapp -Dfilter=com.google.appengine.archetypes:

(hello-habrahabr-webapp — в данном случае это проект ID)

Но поскольку в данном проекте не будет Cloud Points API, то вместо архетипа №2, выбираем архетип №1 (базовый скелет приложения на GAE):
1: remote -> com.google.appengine.archetypes:appengine-skeleton-archetype (A skeleton application with Google App Engine)

остальное — аналогично тому что изложено в первой части, но с другим проект ID.
Таким образом Maven создаст нам папку hello-habrahabr-webapp, со структурой файлов аналогичной предыдущему проекту, но в данном случае директория src/main/java/ будет пуста, и для фронтенда она нам сейчас не потребуется.

Аналогично как мы делали раньше редактируем pom.xml (в <appengine.version>1.9.27</appengine.version> теперь можно сменить на последнюю 1.2.8). Переходим в директорию src/main/webapp/WEB-INF/ редактируем файлы appengine-web.xml и web.xml
web.xml нас теперь почти пуст, поэтому добавим в него (между <web-app> </web-app>) разделы security-constraint и welcome-file-list:
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
    <!-- Force SSL for entire site -->
    <!-- (server automatically redirects the requests to the SSL port
        if you try to use HTTP ) -->
    <security-constraint>
        <web-resource-collection>
            <web-resource-name>Entire Application</web-resource-name>
            <url-pattern>/*</url-pattern>
        </web-resource-collection>
        <user-data-constraint>
            <transport-guarantee>CONFIDENTIAL</transport-guarantee>
        </user-data-constraint>
    </security-constraint>
    <!-- Welcome file list -->
    <welcome-file-list>
        <welcome-file>index.html</welcome-file>
    </welcome-file-list>
    <!--  -->
</web-app>


Создаем в этом проекте скрипты set.account.sh и commit.push.build.and.upload.sh аналогичные тем которые были в первой части, и запускаем команды:
git init
gcloud init
git config credential.helper gcloud.sh
git remote add google https://source.developers.google.com/p/hello-habrahabr-webapp
git push --all google
set.account.sh
commit.push.build.and.upload.sh

Убеждаемся что проект успешно собирается и загружается на сервер.

Maven собирает проект в .war файл находящийся в директории target, и выгружает его на сервер GAE. Этот же файл можно деплоить и на другой совместимый java-сервер (Tomcat, Jetty). Чтобы запустить .war-файл который будет деплоиться на GAE на локальной машине, нужно:

1) иметь установленный Google Cloud SDK (установка рассматривалась в первой части)

2) установить Cloud SDK app-engine-java компонент:
gcloud components update app-engine-java

3) убедиться что на машине по умолчанию Java 7 (деплоить командой mvn appengine:update можно и на 8), если нет, то (на Linux) редактируем ~/.bashrc
export JAVA_HOME=/usr/lib/jvm/java-7-oracle/ # путь к директории с Java 7 (зависит от вашей установки)
export PATH=$JAVA_HOME/bin:$PATH

и перезапускаем командой:
source ~/.bashrc

4) если еще не создан, создаем .war, запустив в директории проекта:
mvn clean install

.war-файл мы сможем найти по адресу в target/{проект ID}-{версия}.war
5) в директории проекта запускаем команду:
mvn gcloud:run

Сервер будет доступен по адресу localhost:8080, а на localhost:8000 будет административная панель локального сервера.

Доступ к API без аутентификации


Начнем с простого. В прошлый раз мы создали простой API доступный без аутентификации ('myApi API') по POST запросу на адрес hello-habrahabr-api.appspot.com/_ah/api/myApi/v1/register ( файл YourFirstAPI.java ). Давайте несколько модифицируем этот файл так чтобы он выдавал информацию в logs, которые напоминаю доступны в консоли разработчика в меню проекта: Monitoring -> Logs, и содержал также метод обрабатывающий GET запрос:
package com.appspot.hello_habrahabr_api;

import com.google.api.server.spi.config.Api;
import com.google.api.server.spi.config.ApiMethod;
import com.google.api.server.spi.config.ApiMethod.HttpMethod;
import java.util.logging.Logger;
import java.util.Random;

@Api(name = "myApi", //The api name must match '[a-z]+[A-Za-z0-9]*'
     version = "v1",
     scopes = {Constants.EMAIL_SCOPE},
     description = "first API for this application.")
public class YourFirstAPI {

  @SuppressWarnings("unused")
  private static final Logger LOG = Logger.getLogger(YourFirstAPI.class.getName());

  @ApiMethod(
             name = "registerPOST",
             path = "register",
             httpMethod = HttpMethod.POST
             )
  @SuppressWarnings("unused")
  public MessageToUser registerPOST(final UserForm userForm) {

    LOG.warning("[YourFirstAPI] (HTTP Method: POST): userForm: {name: " + userForm.getName() + ", age: " + userForm.getAge() + ", ishuman: " + userForm.getIshuman() + "}" );

    MessageToUser messageToUser = new MessageToUser();
    messageToUser.setMessage("Hi, " + userForm.getName() + ", you are registered on our site");
    Random random = new Random();
    messageToUser.setUsernumber(random.nextInt(100) + 1);
    messageToUser.setIsregistered(true);
    return messageToUser;}

  @ApiMethod(
             name = "getGreeting",
             path = "getgreeting",
             httpMethod = HttpMethod.GET
             )
  @SuppressWarnings("unused")
  public MessageToUser getGreeting(final UserForm userForm) {

    LOG.warning("[YourFirstAPI] (HTTP Method: GET): userForm: {name: " + userForm.getName() + ", age: " + userForm.getAge() + ", ishuman: " + userForm.getIshuman() + "}" );

    MessageToUser messageToUser = new MessageToUser();

    messageToUser.setMessage("Hi, " + userForm.getName() + ", nice to meet you :)");
    Random random = new Random();
    messageToUser.setUsernumber(random.nextInt(100) + 1);
    messageToUser.setIsregistered(false);
    return messageToUser;}
}


и задеплоим на сервер (воспользуемся ранее созданным скриптом commit.push.build.and.upload.sh )

Теперь в проекте в котором мы создаем фронтенд создадим форму для такого запроса. Перейдем в директорию src/main/webapp/ — это место, где можно располагать статические файлы, начиная с index.html, директории, кроме WEB-INF/ также будут доступны для запросов. При этом поскольку это все-таки Java-сервер то конечно любой такой файл может также и генерироваться сервлетом.

Итак, index.html:
<!DOCTYPE html>
<html lang="en">

<head>

    <meta charset="utf-8">
    <link rel="shortcut icon" href="favicon.ico?" />

    <!-- JQUERY 2.1.4 -->
    <script src="vendors/jquery-2.1.4.js"></script>
    <!-- BOOTSTRAP 3.3.5 -->
    <link rel="stylesheet" href="vendors/bootstrap.css">
    <link rel="stylesheet" href="vendors/bootstrap-theme.css">
    <script src="vendors/bootstrap.js"></script>
    <!-- ANGULARJS 1.4.7 -->
    <script src="vendors/angular.js"></script>
    <script src="vendors/angular-route.js"></script>
    <!--
    <script src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap-theme.min.css">
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular.min.js"></script>
    <script src="https://code.angularjs.org/1.4.7/angular-route.min.js"></script>
    -->

    <!-- angular-google-gapi -->
    <!-- https://github.com/maximepvrt/angular-google-gapi/releases -->
    <script src="vendors/angular-google-gapi.js"></script>
    <!-- ngProgress -->
    <!-- https://github.com/VictorBjelkholm/ngProgress -->
    <script src="vendors/ngprogress.js"></script>
    <link rel="stylesheet" href="vendors/ngProgress.css">
    <!-- angular-google-gapi -->
    <!-- https://github.com/maximepvrt/angular-google-gapi/releases -->
    <script src="vendors/angular-google-gapi.js"></script>

    <!--  ~~~ MY APP ~~~ -->
    <!-- main app module -->
    <script src="js/app.js"></script>
    <!-- controllers -->
    <script src="js/simpleformcontroller.js"></script>
    <script src="js/authformcontroller.js"></script>
    <!-- -->
    <title>Cloud Endpoints Frontend</title>

</head>

<body ng-app="myApp">

    <ng-view></ng-view>

</body>

</html>



Мы прописали AngularJS с модулем (из стандартного набора) ngRoute.

А также, для чтобы сайт имел дружественный пользователю вид, добавим Bootstrap (+jQuery), и AngularJS модуль ngProgress. Последний служит для отображения индикаторов процесса (progress bar), мы будем использовать его чтобы показать пользователю ход выполнения запросов.

Мы обозначили angualrjs-приложение как «myApp» () и указали приложению место для вставки шаблонов ( <ng-view></ng-view> ), и указали два angularjs-котроллера: js/simpleformcontroller.js и js/authformcontroller.js, соответственно для работы с запросами с аутентификацией и без оной.

Модуль angular-google-gapi нам потребуется для работы с аутентификацией, и будет рассмотрен далее.

Теперь создадим главный модуль app.js:
'use strict';

(
    function () {
        // create main module
        var app = angular.module("myApp", [
                'ngRoute',   // https://code.angularjs.org/1.4.7/docs/api/ngRoute
                'ngProgress' // https://github.com/victorbjelkholm/ngprogress
            ]
        );
        // routes
        app.config(function ($routeProvider) {
                $routeProvider.when('/', {
                    templateUrl: "templates/simpleform.html",
                    controller: "SimpleFormController"
                }).when('/auth/', {
                        templateUrl: "templates/authform.html",
                        controller: "AuthFormController"
                    })
                    .otherwise({redirectTo: '/'})
            }
        );
    }()
);



И контроллер js/simpleformcontroller.js:
'use strict';

(
    function() {

        var SimpleFormController = function($scope, $http, ngProgressFactory) {

            $scope.progressbar = ngProgressFactory.createInstance();
            $scope.progressbar.setColor('blue');

            $scope.data = {}; // data from form (ng-model="data. ...")

            // $http configuration object
            // https://code.angularjs.org/1.4.7/docs/api/ng/service/$http#usage
            $scope.postReq = {
                method: 'POST',
                url: 'https://hello-habrahabr-api.appspot.com/_ah/api/myApi/v1/register',
                data: $scope.data // 'data' for POST
            };

            $scope.getReq = {
                method: 'GET',
                url: 'https://hello-habrahabr-api.appspot.com/_ah/api/myApi/v1/getgreeting',
                params: $scope.data // 'params:' for GET
            };

            $scope.actionPost = function() {
                $scope.progressbar.start();
                console.log("request sent:"); // for testing
                console.log($scope.postReq); // for testing
                // AJAX request:
                // $http(req).then(function(){...}, function(){...});
                $http($scope.postReq).then(function(response) {
                        // success:
                        $scope.progressbar.complete();
                        $scope.serverresponse = response;
                        $scope.errormessage = null;
                        console.log("response received:");
                        console.log(response);
                    }, // comma operator, see: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Comma_Operator
                    function(response) {
                        // error:
                        $scope.progressbar.complete();
                        $scope.errormessage = response;
                        $scope.serverresponse = null;
                        console.log("get Error form server:");
                        console.log(response);
                    });
            };

            $scope.actionGet = function() {
                $scope.progressbar.start();
                console.log("request sent:"); // for testing
                console.log($scope.getReq); // for testing
                // AJAX request:
                // $http(req).then(function(){...}, function(){...});
                $http($scope.getReq).then(function(response) {
                        // success:
                        $scope.progressbar.complete();
                        $scope.serverresponse = response;
                        $scope.errormessage = null;
                        console.log("response received:");
                        console.log(response);
                    }, // comma operator, see: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Comma_Operator
                    function(response) {
                        // error:
                        $scope.progressbar.complete();
                        $scope.errormessage = response;
                        $scope.serverresponse = null;
                        console.log("get Error form server:");
                        console.log(response);
                    });
            };

            $scope.clear = function() {
                $scope.serverresponse = null;
                $scope.errormessage = null;
                $scope.data = null;
            };
        };

        // $inject property annotation
        // see: https://code.angularjs.org/1.4.7/docs/guide/di
        SimpleFormController.$inject = ['$scope', '$http', 'ngProgressFactory'];

        angular.module('myApp')
            .controller('SimpleFormController', SimpleFormController);

    }());



Итак: мы создали модуль «MyApp», контроллер «SimpleFormController» и в нем функции для GET и POST запросов к 'myApi API' (которые в свою очередь обрабатываются кодом в файле YourFirstAPI.java ) В данном случае загрузку данных с сервера мы будем делать по нажатии кнопок, но конечно можно загружать данные и сразу при загрузке контроллера.

Теперь создаем шаблон templates/simpleform.html в которой будет html-форма для запроса к API и будет отображаться ответ сервера:

<div class="container">
    <!-- Navigation Bar  -->
    <nav class="navbar navbar-inverse">
        <a class="navbar-brand" href="#/">Front-end for Cloud Endpoints API</a>
        <ul class="nav navbar-nav">
            <li class="active"><a href="#/">Home</a></li>
            <li><a href="#/auth/">Auth</a></li>
        </ul>
    </nav>
    <!-- Form -->
    <form>
        <fieldset>
            <legend style="font-weight: bold;">Submit your data to server</legend>
            <p>
                <label> Name:
                    <input ng-model="data.name">
                </label>
            </p>
            <label> Age:
                <p>
                    <input type="range" min="0" max="120" step="1" ng-model="data.age">
                </p>
                <p>
                    <input ng-model="data.age" name="ageNumber" class="col-md-3">
                    <span class="col-md-5">years old</span>
                </p>
            </label>
            <br>
            <label>
                <p> Is a human:
                    <input type="checkbox" ng-model="data.ishuman" name="data.ishuman">
                </p>
            </label>
        </fieldset>
        <input type="button" value="Submit GET" ng-click="actionGet()">
        <input type="button" value="Submit POST" ng-click="actionPost()">
        <input type="reset" ng-click="clear()">
    </form>
    <!-- Server Response -->
    <br>
    <div class="panel panel-default">
        <div ng-hide="(serverresponse != null || errormessage != null)" class="panel-body">
            <p></p>
            <br>
            <p></p>
            <br>
        </div>
        <div ng-show="serverresponse != null" class="panel-body">
            <p>Response from server: </p>
            <p>
                {{ serverresponse.data.message }} and your number is: {{serverresponse.data.usernumber}}
            </p>
        </div>
        <div ng-show="errormessage != null" class="panel-body">
            <p>Error from server: </p>
            <p>
                {{errormessage.data.error.message}}
            </p>
        </div>
    </div>
</div>



Итого на данном этапе: index.html, templates/simpleform.html, js/app.js, js/simpleformcontroller.js

Для проверки запускаем сервер в директории src/main/webapp/:
python -m SimpleHTTPServer

и тестируем приложение:

image

image

Если все нормально — деплоим.

Доступ к API с аутентификацией с использованием логина-пароля учетной записи Google (OAuth 2.0)


Подготовка бэкенда


В начале в консоли разработчика в бэкенд проекта (у нас это был hello-habrahabr-api ) в меню:
APIs & auth -> Credentials -> Add Credentials -> OAuth 2.0 Client ID
мы должны прописать адреса доменов с которых Javascript может обращаться к API (Authorized JavaScript origins) используя OAuth 2.0 от Google, т.е. в нашем случае мы пропишем hello-habrahabr-webapp.appspot.com и/или свой домен на который мы сделали SSL сертификаты, как обсуждалось выше. Если необходима возможность логиниться с локального сервера, то нужно прописать localhost + номер порта, например localhost:8000

В поле 'Name' вписываем произвольное имя конфигурации, например 'Web Client', и сохраняем конфигурацию кнопкой «Save»:

image

Нужно скопировать содержимое поля «Client ID», вида: 647700180043-m8et0au4vhgiv2n4iqr2hssn0mkkl7q0.apps.googleusercontent.com, или загрузить JSON-файл со всеми параметрами конфигурации кликнув по пиктограмме, которая на картинке отмечена красным.

В проекте с API (у нас это hello-habrahabr-api) в файле Constants.java: WEB_CLIENT_ID нужно присвоить значение Client ID, а EMAIL_SCOPE — «www.googleapis.com/auth/userinfo.email»:
package com.appspot.hello_habrahabr_api;

import com.google.api.server.spi.Constant;

/**
 * Contains the client IDs and scopes for allowed clients consuming your API.
 */
public class Constants {
    public static final String WEB_CLIENT_ID = "647700180043-m8et0au4vhgiv2n4iqr2hssn0mkkl7q0.apps.googleusercontent.com";
    public static final String ANDROID_CLIENT_ID = "replace this with your Android client ID";
    public static final String IOS_CLIENT_ID = "replace this with your iOS client ID";
    public static final String ANDROID_AUDIENCE = WEB_CLIENT_ID;

    public static final String EMAIL_SCOPE = "https://www.googleapis.com/auth/userinfo.email";

    public static final String API_EXPLORER_CLIENT_ID = Constant.API_EXPLORER_CLIENT_ID;
}

Теперь модифицируем наш OAuth2Api.java, в аннотации @ Api нужно прописать:
        scopes = {Constants.EMAIL_SCOPE},
        clientIds = {Constants.WEB_CLIENT_ID, Constants.API_EXPLORER_CLIENT_ID},

Это означает что данный класс будет обрабатывать запросы от веб-клиентов которым присвоен указанный Client ID (при этом клиент не только должен передать корректный Client ID, но и запрос должен быть с домена указанного в конфигурации APIs & auth — Credentials) с аутентификацией с использованием email. Если эти параметры не будут указаны в OAuth2Api.java, то на запросы API будет отвечать ошибкой 401. Соответственно, если не указать Constants.API_EXPLORER_CLIENT_ID, то при тестировании в API Explorer, т.е. веб-интерфейсе по адресу вида {проект ID}.appspot.com/_ah/api/explorer также будет выдаваться ошибка 401 (то есть сервер не может произвести аутентификацию пользователя)

OAuth2Api.java в новой редакции:
package com.appspot.hello_habrahabr_api;

/**
 * explore on: https://apis-explorer.appspot.com/apis-explorer/?base=https://hello-habrahabr-api.appspot.com/_ah/api#p/oAuth2Api/v1/
 */

import com.google.api.server.spi.config.Api;
import com.google.api.server.spi.config.ApiMethod;
import com.google.api.server.spi.config.ApiMethod.HttpMethod;
import com.google.api.server.spi.response.UnauthorizedException;
import com.google.appengine.api.users.User;
import com.google.appengine.repackaged.com.google.gson.Gson;

import java.util.Random;
import java.util.logging.Logger;

@Api(name = "oAuth2Api", // The api name must match '[a-z]+[A-Za-z0-9]*'
        version = "v1",
        scopes = {Constants.EMAIL_SCOPE},
        clientIds = {Constants.WEB_CLIENT_ID, Constants.API_EXPLORER_CLIENT_ID},
        description = "API using OAuth2")
public class OAuth2Api {

    @SuppressWarnings("unused")
    private static final Logger LOG = Logger.getLogger(OAuth2Api.class.getName());

    @ApiMethod(
            name = "getUserInfo",
            path = "getuserinfo",
            httpMethod = HttpMethod.POST
    )
    @SuppressWarnings("unused")
    public MessageToUser getUserInfo(final User user, final UserForm userForm)
            throws UnauthorizedException {

        if (user == null) {
            LOG.warning("User not logged in");
            throw new UnauthorizedException("Authorization required");
        }

        MessageToUser messageToUser = new MessageToUser();
        Gson gson = new Gson();
        messageToUser.setMessage(
                "Hi, " +
                        userForm.getName() +
                        ", your data from Google: " +
                        gson.toJson(user)
        );
        Random random = new Random();
        messageToUser.setUsernumber(random.nextInt(100) + 1);
        messageToUser.setIsregistered(true);

        return messageToUser;
    }
}


Можем протестировать наш API в в API Explorer, и перейдем к фронтенду.

Фронтенд для запросов с аутентификацией


Для доступа к API с аутентификацией Google предоставляет библиотеку API Client Library for JavaScript, для нашего приложения мы будем использовать на веб-клиенте AngularJS модуль angular-google-gapi, который по сути представляет собой обертку для указанной библиотеке и делает ее использование с AngularJS гораздо более удобным. Базовые инструкции по использованию модуля см. на github.com/maximepvrt/angular-google-gapi

Мы добавили
<script src="vendors/angular-google-gapi.js"></script> 

в index.html

Нужно добавить зависимость в главный модуль приложения ( var app = angular.module('myApp', ['angular-google-gapi']); ), и установить конфигурацию с помощью app.run (… ).
Выглядеть это будет так (js/app.js):
'use strict';

(
    function () {

        // create main module
        var app = angular.module("myApp", [
            'ngRoute', // https://code.angularjs.org/1.4.7/docs/api/ngRoute
            'ngProgress', // https://github.com/victorbjelkholm/ngprogress
            'angular-google-gapi' // https://github.com/maximepvrt/angular-google-gapi/ - add app.run() after app.config()
        ]);

        // routes
        app.config(function ($routeProvider) {
            $routeProvider.when('/', {
                templateUrl: "templates/simpleform.html",
                controller: "SimpleFormController"
            }).when('/auth/', {
                templateUrl: "templates/authform.html",
                controller: "AuthFormController"
            }).otherwise({redirectTo: '/'})
        });

        app.run(['GAuth', 'GApi', 'GData', '$rootScope',
            function (GAuth,
                      GApi,
                      GData,
                      $rootScope) {
                $rootScope.gdata = GData; //

                var CLIENT = '647700180043-m8et0au4vhgiv2n4iqr2hssn0mkkl7q0.apps.googleusercontent.com';
                var BASE = 'https://hello-habrahabr-api.appspot.com/_ah/api';
                GApi.load('oAuth2Api', 'v1', BASE);
                GAuth.setClient(CLIENT);

                // see: https://github.com/maximepvrt/angular-google-gapi/issues/8
                GAuth.setScope("https://www.googleapis.com/auth/userinfo.email");

                GAuth.checkAuth().then(
                    function () {
                        // action if it's possible to authenticate user at startup of the application
                        console.log("user authenticated, $rootScope.gapi.user: ");
                        console.log(console.log($rootScope.gapi.user));
                    },
                    function () {
                        // action if it's impossible to authenticate user at startup of the application
                        console.log("user not authenticated, $rootScope.gapi.user: ");
                        console.log(console.log($rootScope.gapi.user));
                    }
                );

                $rootScope.login = function () { // shows auth window from Google
                    GAuth.login().then(
                        function () {
                            console.log('user logged in');
                            console.log(console.log($rootScope.gapi.user));
                        });
                };

                /*
                 *  As stated on
                 *  https://cloud.google.com/appengine/docs/java/endpoints/consume_js:
                 *  "There is no concept of signing out using the JS client library,
                 *  but you can emulate the behavior if you wish by any of these methods:
                 *  - Resetting the UI to the pre signed-in state.
                 *  - Setting any signed-in variables in your application logic to false
                 *  - Calling gapi.auth.setToken(null);" // <- *
                 * */
                $rootScope.logout = function () {
                    GAuth.logout().then(
                        function () {
                            console.log('user logged out');
                            console.log(console.log($rootScope.gapi.user));
                        });
                };
            }
        ]);
    }()
);



Обратите внимание, что функции login() и logout() мы разместили в '$rootScope', в нашем примере это не принципиально, но таким образом они будут доступны в разных частях приложения (когда будут эти разные части)

Теперь контроллер js/authformcontroller.js:
'use strict';

(
    function () {

        var AuthFormController = function ($scope, $rootScope, $http, GApi, GAuth, ngProgressFactory) {

            $scope.progressbar = ngProgressFactory.createInstance();
            $scope.progressbar.setColor('#00F');

            $scope.data = {}; // object to store form data

            $scope.authrequest = function () {

                $scope.progressbar.start();
                GAuth.checkAuth().then(
                    function () {
                        // if it's possible to authenticate user
                        GApi.executeAuth( // execute request
                            'oAuth2Api',
                            'getUserInfo',
                            $scope.data // data to send to server
                            )
                            .then(function (resp) {  // receive response
                                $scope.userinfofromserver = resp;
                                console.log("GApi.executeAuth() get resp:");
                                console.log(resp);
                                $scope.progressbar.complete();
                            });
                    },
                    function () {
                        // if it's impossible to authenticate user
                        $scope.userinfofromserver = {};
                        $scope.userinfofromserver.message = 'User not logged in ';
                        console.log("GAuth.checkAuth() - failed: ");
                        console.log(console.log($rootScope.gapi.user));
                        $scope.progressbar.complete();
                    }
                );
            };

            $scope.clear = function () {
                $scope.userinfofromserver = null;
                $scope.data = null;
            };

        };

        // $inject property annotation
        // see: https://code.angularjs.org/1.4.7/docs/guide/di
        AuthFormController.$inject = ['$scope', '$rootScope', '$http', 'GApi', 'GAuth', 'ngProgressFactory'];

        angular.module('myApp')
            .controller('AuthFormController', AuthFormController);

    }()

);



Обратите внимание что прежде чем запустить GApi.executeAuth(), мы запускаем GAuth.checkAuth() — это отличается от подхода используемого самим автором модуля, но мне кажется это более логичным, GApi.executeAuth() — обновит аутентификацию прежде чем посылать запрос, таким образом пользователь не будет получать ошибки авторизации при запросе после долгого периода бездействия на сайте и т.п.

В $rootScope у нас теперь будет содержаться объект gapi.user, который содержит доступную информацию о залогиненном пользователе:
  • user.email
  • user.picture (аватар пользователя, url на картинку)
  • user.id (Google id (номер))
  • user.name (имя пользователя, или если оно отсутствует, то email)
  • user.link (ссылка на страницу пользователя Google+, если она существует)

Таким образом в шаблоне мы можем залогиненному пользователю показывать его фото и email, воспользуемся этим в templates/authform.html:
<div class="container">
    <!-- Navigation Bar  -->
    <nav class="navbar navbar-inverse">
        <a class="navbar-brand" href="#/">Front-end for Cloud Endpoints API</a>
        <ul class="nav navbar-nav">
            <li><a href="#/">Home</a></li>
            <li class="active"><a href="#/auth/">Auth</a></li>
        </ul>
        <!--  -->
        <ul class="nav navbar-nav navbar-right">
            <li>
                <a ng-show="gapi.user" href="" class="navbar-brand" style="padding-top: 0;">
                    <img src="{{gapi.user.picture}}" class="navbar-brand"
                         style="padding-top: 0; padding-bottom: 0;"></a>
            </li>
        </ul>
        <!--  -->
        <ul class="nav navbar-nav navbar-right">
            <!--  -->
            <li ng-show="!gapi.user" class="active">
                <a href="" ng-click="login()">login</a>
            </li>
            <!--  -->
            <li ng-show="gapi.user">
                <a href="">{{gapi.user.email}}</a>
            </li>
            <li ng-show="gapi.user">
                <a href="" ng-click="logout()">logout</a>
            </li>
        </ul>
    </nav>
    <!-- Form -->
    <form>
        <fieldset>
            <legend style="font-weight: bold;">Submit your request to server</legend>
            <p>
                <label> Your name:
                    <input ng-model="data.name">
                </label>
            </p>
        </fieldset>
        <input type="button" value="Auth request" ng-click="authrequest()">
        <input type="reset" ng-click="clear()">
    </form>
    <!-- Server Response -->
    <br>

    <div class="panel panel-default" style="font-weight: bold;" >
        <!--  -->
        <div ng-hide="(userinfofromserver != null)" class="panel-body">
            <p></p>
            <br>

            <p></p>
            <br>
        </div>
        <!--  -->
        <div ng-show="userinfofromserver" class="panel-body"
             style="">
            <p> User's data from server (reg # {{userinfofromserver.usernumber}}): </p>

            <p>{{userinfofromserver.message}}</p>
        </div>
        <!--  -->
    </div>
</div>


Перегрузим домашнюю страницу нашего сайта:

image

В консоли мы видим, что скрипт пытался сразу распознать пользователя, но пользователь пока «незнакомый».
Идем по ссылке 'Auth':
image
Вводим имя в поле 'Your name' и жмем кнопку 'Auth request':
image
Теперь логинимся (ссылка 'login' на навигационной панели), используя учетную запись Google:
image
После логина наша навигационная панель будет показывать фото пользователя из учетной записи Google, email с которым залогинился пользователь, и ссылку 'logout'.
Если мы теперь снова пошлем запрос на сервер (кнопкой 'Auth request'), получим ответ сервера как подобает залогиненному пользователю:
image

Таким способом мы можем реализовать связь веб-клиента и сервера по защищенному каналу и с аутентификацией пользователей без необходимости хранить пароли на собственном сервере. Как я уже говорилось в предыдущей статье, возможно трендом станет отсутствие собственной аутентификации логин-пароль на сайтах и использование аутентификации OAuth 2.0 надежных провайдеров.

Дополнительные материалы:


Комментарии (2)


  1. bromzh
    18.11.2015 21:41

    Взаимодействие с бэкендом в ангуляре надо вынести из контроллеров в сервис. А сами контроллеры лучше сделать в виде классов, убрать из них инъекцию $scope (если она явно не нужна) и использовать «controllerAs».


    1. ageyev
      20.11.2015 14:01

      Да.
      И вместо ngRoute лучше использовать ui.router как в примере ( github.com/maximepvrt/angular-google-gapi/tree/gh-pages ) от авторов angular-google-gapi

      И, поскольку tutorial, оставлю здесь ссылку на статью: Digging into Angular’s “Controller as” syntax by Todd Motto.