Этот пост является третьим в серии статей, которые я пишу о скалатре. В «части I» мы создали исходную среду, а в «части II» мы создали первую часть REST API и добавили несколько тестов. В этой третьей части руководства по скалатре мы рассмотрим следующие темы:
- Постоянство: мы используем scalaquery для сохранения элементов нашей модели.
- Безопасность: обрабатывать заголовок безопасности, содержащий ключ API.
Фрист, мы посмотрим на постоянство части. Для этой части мы будем использовать scalaquery . Обратите внимание, что код, который мы здесь показываем, в значительной степени такой же, как и для преемника scalaquery. Slick , однако, требует Scala 2.10.0-M7, и это будет означать, что мы должны изменить нашу полную настройку Scala. Так что для этого примера мы будем просто использовать scalaquery (чей синтаксис такой же, как у slick). Если вы еще этого не сделали, установите JRebel, чтобы ваши изменения были отражены мгновенно без перезапуска службы.
живучесть
Я использовал postgresql для этого примера, но можно использовать любую из баз данных, поддерживаемых scalaquery. Модель базы данных, которую я использовал, очень проста:
CREATE TABLE sc_bid ( id integer NOT NULL DEFAULT nextval('sc_bid_id_seq1'::regclass), "for" integer, min numeric, max numeric, currency text, bidder integer, date numeric, CONSTRAINT sc_bid_pkey1 PRIMARY KEY (id ), CONSTRAINT sc_bid_bidder_fkey FOREIGN KEY (bidder) REFERENCES sc_user (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT sc_bid_for_fkey FOREIGN KEY ("for") REFERENCES sc_item (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ) CREATE TABLE sc_item ( id integer NOT NULL DEFAULT nextval('sc_bid_id_seq'::regclass), name text, price numeric, currency text, description text, owner integer, CONSTRAINT sc_bid_pkey PRIMARY KEY (id ), CONSTRAINT sc_bid_owner_fkey FOREIGN KEY (owner) REFERENCES sc_user (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ) CREATE TABLE sc_user ( id serial NOT NULL, username text, firstname text, lastname text, CONSTRAINT sc_user_pkey PRIMARY KEY (id ) )
Как вы можете простую модель, с парой внешних ключей и первичных ключей, которые генерируются автоматически. Мы определяем таблицу для пользователей, для товаров и предложений. Обратите внимание, что это зависит от базы данных, так что это будет работать только для postgresql. Дополнительная заметка о postgresql и scalaquery. Scalaquery не поддерживает схемы. Это означает, что мы должны определить таблицы в «публичной» схеме.
Прежде чем мы сможем начать работу с scalaquery, мы должны сначала добавить его в наш проект. В build.sbt добавьте следующие зависимости
"org.scalaquery" %% "scalaquery" % "0.10.0-M1", "postgresql" % "postgresql" % "9.1-901.jdbc4"
После обновления у вас будут необходимые фляги для scalaquery и postgres. Давайте посмотрим на один из репозиториев: bidrepository и признак RepositoryBase.
// the trait import org.scalaquery.session.Database trait RepositoryBase { val db = Database.forURL("jdbc:postgresql://localhost/dutch_gis?user=jos&password=secret", driver = "org.postgresql.Driver") } // simple implementation of the bidrepository package org.smartjava.scalatra.repository import org.smartjava.scalatra.model.Bid import org.scalaquery.session._ import org.scalaquery.ql.basic.{BasicTable => Table} import org.scalaquery.ql.TypeMapper._ import org.scalaquery.ql._ import org.scalaquery.ql.extended.PostgresDriver.Implicit._ import org.scalaquery.session.Database.threadLocalSession class BidRepository extends RepositoryBase { object BidMapping extends Table[(Option[Long], Long, Double, Double, String, Long, Long)]("sc_bid") { def id = column[Option[Long]]("id", O PrimaryKey) def forItem = column[Long]("for", O NotNull) def min = column[Double]("min", O NotNull) def max = column[Double]("max", O NotNull) def currency = column[String]("currency") def bidder = column[Long]("bidder", O NotNull) def date = column[Long]("date", O NotNull) def noID = forItem ~ min ~ max ~ currency ~ bidder ~ date def * = id ~ forItem ~ min ~ max ~ currency ~ bidder ~ date } /** * Return a Option[Bid] if found or None otherwise */ def get(bid: Long, user: String) : Option[Bid] = { var result:Option[Bid] = None; db withSession { // define the query and what we want as result val query = for (u <-BidMapping if u.id === bid) yield u.id ~ u.forItem ~ u.min ~ u.max ~ u.currency ~ u.bidder ~ u.date // map the results to a Bid object val inter = query mapResult { case(id,forItem,min,max,currency,bidder,date) => Option(new Bid(id,forItem, min, max, currency, bidder, date)); } // check if there is one in the list and return it, or None otherwise result = inter.list match { case _ :: tail => inter.first case Nil => None } } // return the found bid result } /** * Create a bid using scala query. This will always create a new bid */ def create(bid: Bid): Bid = { var id: Long = -1; // start a db session db withSession { // create a new bid val res = BidMapping.noID insert (bid.forItem.longValue, bid.minimum.doubleValue, bid.maximum.doubleValue, bid.currency, bid.bidder.toLong, System.currentTimeMillis()); // get the autogenerated bid val idQuery = Query(SimpleFunction.nullary[Long]("LASTVAL")); id = idQuery.list().head; } // create a bid to return val createdBid = new Bid(Option(id), bid.forItem, bid.minimum, bid.maximum, bid.currency, bid.bidder, bid.date); createdBid; } /** * Delete a bid */ def delete(user:String, bid: Long) : Option[Bid] = { // get the bid we're deleting val result = get(bid,user); // delete the bid val toDelete = BidMapping where (_.id === bid) db withSession { toDelete.delete } // return deleted bid result } }
Выглядит сложно, верно? Мы сделаем это не раз, когда вы поймете, как работает скаляр. С помощью scalaquery вы создаете отображение таблицы. В этом отображении вы указываете тип полей, которые вы ожидаете. В этом примере наша таблица сопоставления выглядит следующим образом:
object BidMapping extends Table[(Option[Long], Long, Double, Double, String, Long, Long)]("sc_bid") { def id = column[Option[Long]]("id", O PrimaryKey) def forItem = column[Long]("for", O NotNull) def min = column[Double]("min", O NotNull) def max = column[Double]("max", O NotNull) def currency = column[String]("currency") def bidder = column[Long]("bidder", O NotNull) def date = column[Long]("date", O NotNull) def noID = forItem ~ min ~ max ~ currency ~ bidder ~ date def * = id ~ forItem ~ min ~ max ~ currency ~ bidder ~ date }
Здесь мы определяем отображение таблицы «sc_bid». Для каждого поля мы определяем имя столбца и его тип. Если мы хотим, мы можем добавить конкретные параметры, которые учитываются при создании вашего ddl из этого (не то, что я использовал для этого примера). Последние два определения определяют «конструкторы» для этого отображения. «Def *» — это конструктор по умолчанию, где у нас есть все поля заранее, «def noID» — это то, что мы будем использовать, когда создадим ставку в первый раз, и у нас пока нет идентификатора. Помните, что идентификаторы автоматически генерируются базой данных.
С этим отображением мы можем начать писать наши функции репозитория. Начнем с первого: получить
/** * Return a Option[Bid] if found or None otherwise */ def get(bid: Long, user: String) : Option[Bid] = { var result:Option[Bid] = None; db withSession { // define the query and what we want as result val query = for (u <-BidMapping if u.id === bid) yield u.id ~ u.forItem ~ u.min ~ u.max ~ u.currency ~ u.bidder ~ u.date // map the results to a Bid object val inter = query mapResult { case(id,forItem,min,max,currency,bidder,date) => Option(new Bid(id,forItem, min, max, currency, bidder, date)); } // check if there is one in the list and return it, or None otherwise result = inter.list match { case _ :: tail => inter.first case Nil => None } } // return the found bid result }
Здесь вы можете видеть, что мы используем стандартную конструкцию scala для создания итерации запроса по таблице, отображенной с помощью BidMapping. Чтобы убедиться, что мы получаем только то поле, которое нам нужно, мы применяем фильтр, используя выражение «if u.id === bid». В выражении yield мы указываем поля, которые мы хотим вернуть. Используя mapResult в запросе, мы можем обработать результаты запроса и преобразовать его в наш объект case и добавить его в список. Затем мы проверяем, действительно ли что-то есть в списке и возвращаем Option [Bid]. Обратите внимание, что это может быть написано более кратко, но это хорошо объясняет шаги, которые вы должны предпринять.
Следующая функция создать
def create(bid: Bid): Bid = { var id: Long = -1; // start a db session db withSession { // create a new bid val res = BidMapping.noID insert (bid.forItem.longValue, bid.minimum.doubleValue, bid.maximum.doubleValue, bid.currency, bid.bidder.toLong, System.currentTimeMillis()); // get the autogenerated bid val idQuery = Query(SimpleFunction.nullary[Long]("LASTVAL")); id = idQuery.list().head; } // create a bid to return val createdBid = new Bid(Option(id), bid.forItem, bid.minimum, bid.maximum, bid.currency, bid.bidder, bid.date); createdBid; }
Теперь мы используем пользовательский noid BidMapping для конструктора, чтобы сгенерировать оператор вставки. Если мы не указали noID, мы должны уже указать id. Теперь, когда мы вставили новый объект Bid в базу данных, нам нужно вернуть только что созданный Bid с новым идентификатором пользователю. Для этого нам нужно выполнить простой запрос под названием «LASTVAL», который возвращает последнее автоматически сгенерированное значение. В нашем случае это идентификатор созданной ставки. Из этой информации мы создаем новую ставку, которую мы возвращаем.
Последняя операция для нашего репозитория — это функция удаления. Эта функция сначала проверяет наличие указанной ставки и, если она есть, удаляет ее.
def delete(user:String, bid: Long) : Option[Bid] = { // get the bid we're deleting val result = get(bid,user); // delete the bid val toDelete = BidMapping where (_.id === bid) db withSession { toDelete.delete } // return deleted bid result }
Здесь мы используем фильтр ‘where’ для создания запроса, который мы хотим выполнить. Когда мы вызываем delete в этом фильтре, все соответствующие элементы удаляются. И это самое простое использование скалябства для настойчивости. Если вам нужны более сложные операции (например, объединения), посмотрите примеры на сайте scalaquery.org.
Теперь у нас есть функциональность для создания и удаления ставок. Поэтому было бы неплохо, если бы у нас был какой-то способ аутентификации наших пользователей. Для этого урока мы собираемся создать очень простую схему аутентификации на основе API-ключей. Для каждого запроса пользователь должен добавить определенный заголовок со своим ключом API. Затем мы можем использовать информацию из этого ключа, чтобы определить, кто этот пользователь, и может ли он удалить или получить доступ к конкретной информации.
Безопасность
Начнем с части генерации ключей. Когда кто-то хочет использовать наш API, мы требуем от него указать имя приложения и имя хоста, с которого будет сделан запрос. Эту информацию мы будем использовать для генерации ключа, который они должны использовать в каждом запросе. Этот ключ — простой хэш HMAC.
package org.smartjava.scalatra.util import javax.crypto.spec.SecretKeySpec import javax.crypto.Mac import org.apache.commons.codec.binary.Base64 object SecurityUtil { def calculateHMAC(secret: String, applicationName: String , hostname: String ) : String = { val signingKey = new SecretKeySpec(secret.getBytes(),"HmacSHA1"); val mac = Mac.getInstance("HmacSHA1"); mac.init(signingKey); val rawHmac = mac.doFinal((applicationName + "|" + hostname).getBytes()); new String(Base64.encodeBase64(rawHmac)); } def checkHMAC(secret: String, applicationName: String, hostname: String, hmac: String) : Boolean = { return calculateHMAC(secret, applicationName, hostname) == hmac; } def main(args: Array[String]) { val hmac = SecurityUtil.calculateHMAC("The passphrase to calculate the secret with","App 1","localhost"); println(hmac); println(SecurityUtil.checkHMAC("The passphrase to calculate the secret with","App 1","localhost",hmac)); } }
Приведенный выше вспомогательный объект используется для вычисления начального хэша, который мы отправляем пользователю, и может использоваться для проверки входящего хэша. Чтобы использовать это в нашем REST API, нам нужно перехватить все входящие запросы и проверить эти заголовки перед вызовом определенного маршрута. С помощью scalatra мы можем сделать это с помощью функции before ():
package org.smartjava.scalatra.routes import org.scalatra.ScalatraBase import org.smartjava.scalatra.repository.KeyRepository /** * When this trait is used, the incoming request * is checked for authentication based on the * X-API-Key header. */ trait Authentication extends ScalatraBase { val ApiHeader = "X-API-Key"; val AppHeader = "X-API-Application"; val KeyChecker = new KeyRepository; /** * A simple interceptor that checks for the existence * of the correct headers */ before() { // we check the host where the request is made val servername = request.serverName; val header = Option(request.getHeader(ApiHeader)); val app = Option(request.getHeader(AppHeader)); List(header,app) match { case List(Some(x),Some(y)) => isValidHost(servername,x,y); case _ => halt(status=401, headers=Map("WWW-Authenticate" -> "API-Key")); } } /** * Check whether the host is valid. This is done by checking the host against * a database with keys. */ private def isValidHost(hostName: String, apiKey: String, appName: String): Boolean = { KeyChecker.validateKey(apiKey, appName, hostName); } }
Эта черта, которую мы включили в наш основной сервлет скалатры, получает правильную информацию из запроса и проверяет, соответствует ли предоставленный хеш сгенерированным кодом, который вы видели ранее. Если это так, запрос передается, если нет, мы останавливаем обработку запроса и отправляем обратно 401, объясняющий, как пройти аутентификацию с помощью этого API.
Если клиент пропустит эти заголовки, он получит это в ответ:
Если клиент отправит правильные заголовки, он получит следующий ответ:
вот и все для этой части. В следующей части мы рассмотрим внедрение Indency, CQRS, Akka и запуск этого кода в облаке.