Статьи

Учебник. Начало работы со Scala и Scalatra — Часть III

Этот пост является третьим в серии статей, которые я пишу о скалатре. В «части 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.

Если клиент пропустит эти заголовки, он получит это в ответ:
Dev HTTP Client-2.png

Если клиент отправит правильные заголовки, он получит следующий ответ:
Dev HTTP Client.png
вот и все для этой части. В следующей части мы рассмотрим внедрение Indency, CQRS, Akka и запуск этого кода в облаке.