diff --git a/app/controllers/SCIMController.scala b/app/controllers/SCIMController.scala index b131b5d..9ab0dbf 100644 --- a/app/controllers/SCIMController.scala +++ b/app/controllers/SCIMController.scala @@ -1,51 +1,244 @@ package controllers -import javax.inject._ import play.api.db.Database import play.api.mvc._ +import play.api.libs.json._ + +import javax.inject._ +import java.sql.ResultSet +import com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException class SCIMController @Inject() (db:Database) extends Controller { - def users(filter:Option[String], count:Option[String], startIndex:Option[String]): Result = { + // ----------- Users ----------- // + + def users(filter:Option[String], count:Option[String], startIndex:Option[String]) = Action { // TODO: Retrieve paginated User Objects // TODO: Allow for an equals and startsWith filters on username - Ok + val query = "SELECT * FROM users" + + filter.map(" WHERE username LIKE '" + _ + "%'").getOrElse("") + + count.map(" LIMIT " + _).getOrElse("") + + startIndex.map(" OFFSET " + _).getOrElse("") + + db.withConnection { conn => + val stmt = conn.createStatement + val rs = stmt.executeQuery(query) + val users: Stream[JsValue] = results(rs)(parseUser) + + Ok(Json.obj("users" -> users)) + } } - def user(uid:String): Result = { + def user(uid:String) = Action { // TODO: Retrieve a single User Object by ID - Ok + val query = s"SELECT * FROM users WHERE id = '${uid}'" + + db.withConnection { conn => + val stmt = conn.createStatement + val rs = stmt.executeQuery(query) + + if (rs.next()) + Ok(parseUser(rs)) + else + NotFound + } } - def createUser(): Result = { + def createUser() = Action { implicit request: Request[AnyContent] => // TODO: Create a User Object with firstname and lastname metadata - Ok + val id = field[String]("id") + val username = field[String]("username") + val firstname = field[String]("firstname") + val lastname = field[String]("lastname") + val active = field[Boolean]("active", false) + + try { + doCreateUser(id, username, firstname, lastname, active) + } catch { + case dup: MySQLIntegrityConstraintViolationException => BadRequest(dup.getMessage) + case _: Throwable => InternalServerError + } } - def updateUser(uid:String): Result = { + def updateUser(uid:String) = Action { implicit request: Request[AnyContent] => // TODO: Update a User Object's firstname, lastname, and active status - Ok + val firstname = fieldOpt[String]("firstname") + val lastname = fieldOpt[String]("lastname") + val active = fieldOpt[Boolean]("active") + + doUpdateUser(Some(uid), firstname, lastname, active) } - def deleteUser(uid:String): Result = { + def deleteUser(uid:String) = Action { // TODO: Delete a User Object by ID - Ok + doDeleteUser(uid) } - def groups(count:Option[String], startIndex:Option[String]): Result = { + // ----------- Groups ----------- // + + def groups(count:Option[String], startIndex:Option[String]) = Action { // TODO: Retrieve paginated Group Objects - Ok + val query = "SELECT * FROM groups" + + count.map(" LIMIT " + _).getOrElse("") + + startIndex.map(" OFFSET " + _).getOrElse("") + + db.withConnection { conn => + val stmt = conn.createStatement + val rs = stmt.executeQuery(query) + val groups: Stream[JsValue] = results(rs)(parseGroup) + + Ok(Json.obj("groups" -> groups)) + } } - def group(groupId:String): Result = { + def group(groupId:String) = Action { // TODO: Retrieve a single Group Object by ID - Ok + val query = s""" + SELECT users.* FROM users + INNER JOIN users_groups + ON users.id = users_groups.user_id + WHERE group_id = '${groupId}'""" + + db.withConnection { conn => + val stmt = conn.createStatement + val rs = stmt.executeQuery(query) + val users: Stream[JsValue] = results(rs)(parseUser) + + Ok(Json.obj( + "id" -> groupId, + "users" -> users)) + } } - def patchGroup(groupId:String): Result = { + def patchGroup(groupId:String) = Action { implicit request: Request[AnyContent] => // TODO: Patch a Group Object, modifying its members - Ok + val add = fieldOpt[List[JsValue]]("add") + val delete = fieldOpt[List[String]]("delete") + val update = fieldOpt[List[JsValue]]("update") + + if (add.isEmpty && delete.isEmpty && update.isEmpty) { + BadRequest("empty patch request") + } else { + // this should probably be in a transaction to roll back in case of an exception + try { + add.map(_.foreach(user => (doCreateUser _).tupled(toUserTuple(user)))) // this ignores the result of calls to doCreateUser + delete.map(_.foreach(doDeleteUser)) // this ignores the result of calls to doDeleteUser + update.map(_.foreach(user => (doUpdateUser _).tupled(toUserOptTuple(user)))) // this ignores the result of calls to doUpdateUser + Ok + } catch { + case dup: MySQLIntegrityConstraintViolationException => BadRequest(dup.getMessage) + case _: Throwable => InternalServerError + } + } } + // ----------- Utilities ----------- // + + // taken from https://stackoverflow.com/questions/9636545/treating-an-sql-resultset-like-a-scala-stream + private def results[T](resultSet: ResultSet)(f: ResultSet => T): Stream[T] = { + new Iterator[T] { + def hasNext = resultSet.next() + def next() = f(resultSet) + }.toStream + } + + private def parseUser(rs: ResultSet): JsValue = + Json.obj( + "id" -> rs.getString("id"), + "username" -> rs.getString("username"), + "firstname" -> rs.getString("firstname"), + "lastname" -> rs.getString("lastname"), + "active" -> (if (rs.getInt("active") == 1) "true" else "false") + ) + + private def parseGroup(rs: ResultSet): JsValue = + Json.obj( + "id" -> rs.getString("id") + ) + + private def toUserTuple(user: JsValue) = + ( + (user \ "id").asOpt[String].getOrElse(null), + (user \ "username").asOpt[String].getOrElse(null), + (user \ "firstname").asOpt[String].getOrElse(null), + (user \ "lastname").asOpt[String].getOrElse(null), + (user \ "active").asOpt[Boolean].getOrElse(false)) + + private def toUserOptTuple(user: JsValue) = + ( + (user \ "id").asOpt[String], + (user \ "firstname").asOpt[String], + (user \ "lastname").asOpt[String], + (user \ "active").asOpt[Boolean]) + + private def doCreateUser(id: String, username: String, firstname: String, lastname: String, active: Boolean) = { + if (id == null) { + BadRequest("missing id") + } else if (username == null) { + BadRequest("missing username") + } else if (firstname == null) { + BadRequest("missing firstname") + } else if (lastname == null) { + BadRequest("missing lastname") + } else { + val query = s""" + INSERT INTO users (id, username, firstname, lastname, active) + VALUES + ('${id}', '${username}', '${firstname}', '${lastname}', ${active}); + """ + + db.withConnection { conn => + val stmt = conn.createStatement + val rc = stmt.executeUpdate(query) + + if (rc == 1) + Ok + else + InternalServerError("failed to create user") + } + } + } + + private def doDeleteUser(id: String) = { + val query = s"DELETE FROM users WHERE id = '${id}'" + + db.withConnection { conn => + val stmt = conn.createStatement + val rc = stmt.executeUpdate(query) + + if (rc == 1) + Ok + else + NotFound + } + } + + private def doUpdateUser(id: Option[String], firstname: Option[String], lastname: Option[String], active: Option[Boolean]) = { + if (id.isEmpty) { + BadRequest("missing id") + } else if (firstname.isEmpty && lastname.isEmpty && active.isEmpty) { + BadRequest("must pass at least one of firstname, lastname, active to update") + } else { + val query = "UPDATE users SET" + + (firstname.map(" firstname = '" + _ + "',").getOrElse("") + + lastname.map(" lastname = '" + _ + "',").getOrElse("") + + active.map(" active = " + _ + ",").getOrElse("")).dropRight(1) + // dropRight removes extra trailing comma + s" WHERE id = '${id.get}'" + + db.withConnection { conn => + val stmt = conn.createStatement + val rc = stmt.executeUpdate(query) + + if (rc == 1) + Ok("user updated") + else + NotFound + } + } + } + + private def field[T: Reads](field: String, default: T = null)(implicit request: Request[AnyContent]) = fieldOpt[T](field).getOrElse(default) + private def fieldOpt[T: Reads](field: String)(implicit request: Request[AnyContent]) = request.body.asJson.map(_ \ field).flatMap(_.asOpt[T]) + } diff --git a/build.sbt b/build.sbt index 010ba6d..c35a63f 100644 --- a/build.sbt +++ b/build.sbt @@ -8,6 +8,8 @@ scalaVersion := "2.11.7" libraryDependencies ++= Seq( javaJdbc, + evolutions, + jdbc, "com.typesafe.play" %% "anorm" % "2.5.0", "mysql" % "mysql-connector-java" % "5.1.23", cache, @@ -16,5 +18,4 @@ libraryDependencies ++= Seq( ) - -fork in run := true \ No newline at end of file +fork in run := false diff --git a/conf/application.conf b/conf/application.conf index 489d3f9..9e5e94d 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -341,11 +341,12 @@ db { # You can declare as many datasources as you want. # By convention, the default datasource is named `default` - # https://www.playframework.com/documentation/latest/Developing-with-the-H2-Database - #default.driver = org.h2.Driver - #default.url = "jdbc:h2:mem:play" - #default.username = sa - #default.password = "" + default { + driver=com.mysql.jdbc.Driver + url="jdbc:mysql://localhost/scim" + username=scim + password="scim" + } # You can turn on SQL logging for any datasource # https://www.playframework.com/documentation/latest/Highlights25#Logging-SQL-statements diff --git a/conf/evolutions/default/1.sql b/conf/evolutions/default/1.sql new file mode 100644 index 0000000..6264caf --- /dev/null +++ b/conf/evolutions/default/1.sql @@ -0,0 +1,16 @@ +# Users schema + +# --- !Ups + +CREATE TABLE users ( + id varchar(36) NOT NULL, + username varchar(256) NOT NULL, + firstname varchar(256) NOT NULL, + lastname varchar(256) NOT NULL, + active tinyint(1) NOT NULL DEFAULT '1', + PRIMARY KEY (id) +); + +# --- !Downs + +DROP TABLE users; diff --git a/conf/evolutions/default/2.sql b/conf/evolutions/default/2.sql new file mode 100644 index 0000000..3e48a1c --- /dev/null +++ b/conf/evolutions/default/2.sql @@ -0,0 +1,12 @@ +# Groups schema + +# --- !Ups + +CREATE TABLE groups ( + id varchar(36) NOT NULL, + PRIMARY KEY (id) +); + +# --- !Downs + +DROP TABLE groups; diff --git a/conf/evolutions/default/3.sql b/conf/evolutions/default/3.sql new file mode 100644 index 0000000..c92c163 --- /dev/null +++ b/conf/evolutions/default/3.sql @@ -0,0 +1,15 @@ +# Groups schema + +# --- !Ups + +CREATE TABLE users_groups ( + user_id varchar(36) NOT NULL, + group_id varchar(36) NOT NULL, + CONSTRAINT users_groups_users_fk FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT users_groups_groups_fk FOREIGN KEY (group_id) REFERENCES groups (id), + UNIQUE KEY user_id_group_id (user_id, group_id) +); + +# --- !Downs + +DROP TABLE users_groups; diff --git a/conf/evolutions/default/4.sql b/conf/evolutions/default/4.sql new file mode 100644 index 0000000..7e6a456 --- /dev/null +++ b/conf/evolutions/default/4.sql @@ -0,0 +1,22 @@ +# Users test data + +# --- !Ups + +INSERT INTO users + (id, username, firstname, lastname, active) +VALUES + ('1cda4679-3ed1-4439-b870-5132bbb286b2', 'gelliot', 'george', 'elliot', 1), + ('286d06ef-28ca-4037-9215-331012b397c5', 'fidofido', 'fyodor', 'dostoevsky', 1), + ('3f03dd45-946a-483c-9da6-4e930189dcef', 'jimbo123', 'james', 'levine', 1), + ('8ef2fc7d-709b-4949-96c7-0c910393ad77', 'anonymouse', 'john', 'doe', 1), + ('e38397c0-2f72-4238-8b15-c1477106c96e', 'vbeffa', 'vlad', 'beffa', 1); + +# --- !Downs + +DELETE FROM users WHERE id IN ( + '1cda4679-3ed1-4439-b870-5132bbb286b2', + '286d06ef-28ca-4037-9215-331012b397c5', + '3f03dd45-946a-483c-9da6-4e930189dcef', + '8ef2fc7d-709b-4949-96c7-0c910393ad77', + 'e38397c0-2f72-4238-8b15-c1477106c96e' +); diff --git a/conf/evolutions/default/5.sql b/conf/evolutions/default/5.sql new file mode 100644 index 0000000..053504f --- /dev/null +++ b/conf/evolutions/default/5.sql @@ -0,0 +1,18 @@ +# Groups test data + +# --- !Ups + +INSERT INTO groups + (id) +VALUES + ('78cd42ed-e5f8-4378-9332-67d44b23b0cf'), + ('84919ed5-3465-40da-9f60-114b9e7a470b'), + ('b7786ae9-27d6-4679-8ab5-5c84b2f72a7a'); + +# --- !Downs + +DELETE FROM groups WHERE id IN ( + '78cd42ed-e5f8-4378-9332-67d44b23b0cf', + '84919ed5-3465-40da-9f60-114b9e7a470b', + 'b7786ae9-27d6-4679-8ab5-5c84b2f72a7a' +); diff --git a/conf/evolutions/default/6.sql b/conf/evolutions/default/6.sql new file mode 100644 index 0000000..c4ad603 --- /dev/null +++ b/conf/evolutions/default/6.sql @@ -0,0 +1,19 @@ +# Users-Groups test data + +# --- !Ups + +INSERT INTO users_groups + (user_id, group_id) +VALUES + -- george elliot + ('1cda4679-3ed1-4439-b870-5132bbb286b2', '78cd42ed-e5f8-4378-9332-67d44b23b0cf'), + ('1cda4679-3ed1-4439-b870-5132bbb286b2', '84919ed5-3465-40da-9f60-114b9e7a470b'), + -- fyodor dostoevsky + ('286d06ef-28ca-4037-9215-331012b397c5', 'b7786ae9-27d6-4679-8ab5-5c84b2f72a7a'), + -- james levine + ('3f03dd45-946a-483c-9da6-4e930189dcef', '84919ed5-3465-40da-9f60-114b9e7a470b'), + ('3f03dd45-946a-483c-9da6-4e930189dcef', 'b7786ae9-27d6-4679-8ab5-5c84b2f72a7a') + +# --- !Downs + +TRUNCATE TABLE users_groups;