diff options
author | Trygve Laugstøl <trygvis@inamo.no> | 2013-05-19 01:14:42 +0200 |
---|---|---|
committer | Trygve Laugstøl <trygvis@inamo.no> | 2013-05-19 10:21:38 +0200 |
commit | c569a4c58cf29778df28392b48e20ffebdbc518e (patch) | |
tree | 5148d1bd03b8efbb281fb4c4dc66d63cc38cf1c9 /src/main/scala | |
download | collection-json-explorer-scala-c569a4c58cf29778df28392b48e20ffebdbc518e.tar.gz collection-json-explorer-scala-c569a4c58cf29778df28392b48e20ffebdbc518e.tar.bz2 collection-json-explorer-scala-c569a4c58cf29778df28392b48e20ffebdbc518e.tar.xz collection-json-explorer-scala-c569a4c58cf29778df28392b48e20ffebdbc518e.zip |
o Initial import of the Scala version of the Collection+JSON explorer.
Diffstat (limited to 'src/main/scala')
-rw-r--r-- | src/main/scala/io/trygvis/cj/Explorer.scala | 73 | ||||
-rw-r--r-- | src/main/scala/io/trygvis/cj/Views.scala | 613 |
2 files changed, 686 insertions, 0 deletions
diff --git a/src/main/scala/io/trygvis/cj/Explorer.scala b/src/main/scala/io/trygvis/cj/Explorer.scala new file mode 100644 index 0000000..9f8fd44 --- /dev/null +++ b/src/main/scala/io/trygvis/cj/Explorer.scala @@ -0,0 +1,73 @@ +package io.trygvis.cj + +import scala.collection.JavaConversions._ +import scala.io.Source +import java.net.{HttpURLConnection, URI} +import java.io.{Writer, StringWriter, PrintWriter, InputStreamReader} +import javax.servlet.http.HttpServletRequest +import net.hamnaberg.json.collection.{NativeJsonCollectionParser, JsonCollection} +import unfiltered.request._ +import unfiltered.response._ +import unfiltered.filter._ +import unfiltered.jetty._ + +case class CjResponse(code: Int, status: String, headers: java.util.Map[String, java.util.List[String]]) + +class Browser extends Plan { + import unfiltered.directives._, Directives._ + + def viewsX(req: HttpServletRequest) = { + + val (proto, port) = if (req.isSecure) + ("https", if (req.getServerPort == 443) "" else ":" + req.getServerPort) + else + ("http", if (req.getServerPort == 80) "" else ":" + req.getServerPort) + + val uri = proto + "://" + req.getServerName + port + "/" + new Views(uri) + } + + def queryParam(name: String) = Directive[Any, Any, String]({ request: HttpRequest[Any] => + request.parameterValues(name).headOption match { + case Some(value) => + Result.Success(value) + case None => + Result.Failure(BadRequest ~> ResponseString("Missing query parameter " + name + "\n")) + } + }) + + def intent = { + Path.Intent { + case "/" => + for { + _ <- GET + req <- underlying[HttpServletRequest] + views = viewsX(req) + } yield Ok ~> Html5(views.index) + case "/render" => + for { + _ <- GET + r <- underlying[HttpServletRequest] + url <- queryParam("url") + params <- QueryParams + views = viewsX(r) + } yield { + println("url=" + url) + val uri = URI.create(url) + val con = uri.toURL.openConnection().asInstanceOf[HttpURLConnection] + con.setRequestProperty("accept", "application/vnd.collection+json") + val content = Source.fromInputStream(con.getInputStream, "utf-8").mkString("") + val headers = con.getHeaderFields.toMap filter {case (key, _) => key != null} + val result = NativeJsonCollectionParser.parseCollection(content) + Ok ~> Html5(views.data(uri, params, result, CjResponse(con.getResponseCode, con.getResponseMessage, headers))) + } + } + } +} + +object Explorer extends App { + Http(8080). + plan(new Browser). + resources(getClass.getResource("/public/")). + run() +} diff --git a/src/main/scala/io/trygvis/cj/Views.scala b/src/main/scala/io/trygvis/cj/Views.scala new file mode 100644 index 0000000..869677e --- /dev/null +++ b/src/main/scala/io/trygvis/cj/Views.scala @@ -0,0 +1,613 @@ +package io.trygvis.cj + +import scala.collection.JavaConversions._ +import scala.xml.{Group, NodeSeq, Elem} +import java.net.{MalformedURLException, URL, URI, URLEncoder} +import net.hamnaberg.json.collection.{Property, Link, Json4sHelpers, JsonCollection} +import net.hamnaberg.json.collection.Render.IMAGE +import org.json4s.native.JsonMethods +import java.io.{PrintWriter, StringWriter, Writer} + +class Views(baseUrl: String) { + + val notSet = <i>Not set</i> + + def tryLink(s: String) = try { + val url = new URL(s) + <a href={url.toExternalForm}>{s}</a> + } catch { + case _: MalformedURLException => <xml:group>{s}</xml:group> + } + + def render(uri: URI) = { + val s = uri.toURL.toExternalForm + baseUrl + "render?url=" + URLEncoder.encode(s, "utf-8") + } + + def delete(uri: URI) = { + val s = uri.toURL.toExternalForm + baseUrl + "render?url=" + URLEncoder.encode(s, "utf-8") + "&action=delete" + } + + def examples: Array[Elem] = Array("minimal", "collection", "item", "queries", "template", "error") map { name => + val cj = baseUrl + "examples/from-spec/" + name + ".collection+json" + <a href={render(URI.create(cj))}>{name}</a> + } + + def getStackTrace(aThrowable: Throwable) = { + val result: Writer = new StringWriter() + val printWriter: PrintWriter = new PrintWriter(result) + aThrowable.printStackTrace(printWriter) + result.toString + } + + /* + mixin get_name(link, prefix, i) + - var name = typeof link.name == 'string' ? link.name : undefined + - var prompt = typeof link.prompt == 'string' ? link.prompt : undefined + - var prefix = typeof prefix == 'string' ? prefix + ': ' : '' + |#{prefix + (name || prompt || '#' + i)} + + */ + def getName(link: Link, prefix: String, i: Int) = { +// var name = link.name + val name = Json4sHelpers.getAsString(link.underlying, "name") + prefix + name.orElse(link.prompt).getOrElse("#" + i) + } + + def index = { + def innerContent = + <div class='hero-unit'> + <h1>❤ Collection+JSON ❤</h1> + + <!-- spacers --> + <div class='row-fluid'> </div> + <div class='row-fluid'> </div> + + <div class='row-fluid'> + <div class='span12 input-append'> + <form action='/render' class='form-search'> + <input type='text' name='url' placeholder='Resource to explore' class='span11'/> + <button type='submit' class='btn btn-primary'>Explore</button> + </form> + </div> + </div> + </div> + <div class="row-fluid"> + <div class="span4"> + <h2>About</h2> + <p>This is an interactive explorer for the Collection+JSON hypermedia. Give it an URL and it will render is as good as it can.</p> + <p> + The purpose is twofold: it is a useful tool to inspect + collections while developing or debugging an application. At + the same time it's useful to show the power of + <a href='http://en.wikipedia.org/wiki/Hypermedia'>hypermedia</a> + by showing how much a generic user agent can do by using + only the generic Collection+JSON specification and not + knowing anything about your application. + </p> + <h3>See also</h3> + <p> + There's a growing C+J community that's discussing on the <a href="https://groups.google.com/forum/#!forum/collectionjson">Google Group</a>. + </p> + <p> + Reading the (quite light) <a href="http://amundsen.com/media-types/collection/">formal specification</a> is + useful. It also has a tutorial and some examples. + </p> + </div> + <div class="span4"> + <h2>Using</h2> + <p> + Feel to use this service! However, note that it's running on a free + <a href="http://heroku.com">Heroku</a> + instance so it might fail, be slow or otherwise useless.</p> + <p> + If you want to run it against your own local servers you can either run it yourself, or use apps like + <a href="http://localtunnel.com">localtunnel.com</a> to make your application publicly available. + </p> + <h3>The Source Code</h3> + <p> + The source code is available + <a href="http://trygvis.dyndns.org/~trygvis/git/2012/06/collection+json-explorer.git">here</a>. + </p> + </div> + <div class="span4"> + <h2>Examples</h2> + <p> + The <a href="http://employee.herokuapp.com">Employee</a> application is a set of resources with employees + and departments. The application was made specifically for this explorer: + <a href={render(URI.create("http://employee.herokuapp.com"))} class="label label-info">explore now!</a> + </p> + <p>The specification contains a few example collections too which + you can explore: + </p> + <ul> + {examples.map {example => <li>{example}</li>}} + </ul> + </div> + </div> + ; + + def content = <div class="offset2 span10">{innerContent}</div> + layout(content, None) + } + + def data(url: URI, params: Map[String, Seq[String]], result: Either[Throwable, JsonCollection], res: CjResponse) = { + + def href(uri: URI) = { +// val splits = uri.getPath.split('/') +// for split in splits +// a(href=urlgenerator.render(split[1]), title='Explore #{split[1]}') #{split[0]} + uri.toURL.toExternalForm + } + + def link(link: Link) = { + val name = Json4sHelpers.getAsString(link.underlying, "name") + <xml:group> + <div> + <a class="btn btn-primary btn-mini" href={render(link.href)}>Explore</a> + <a class="btn btn-primary btn-mini" href={link.href.toURL.toExternalForm}>Raw</a> + </div> + <dl> + <dt>href</dt> + <dd><div>{href(link.href)}</div></dd> + <dt>rel</dt> + <dd>{tryLink(link.rel)}</dd> + <dt>name</dt> + <dd>{name.getOrElse(notSet)}</dd> + <dt>prompt</dt> + <dd>{link.prompt.getOrElse(notSet)}</dd> + <dt>render</dt> + <dd>{link.render.map(_.name).getOrElse(notSet)}</dd> + {link.render match { + case Some(IMAGE) => + <dt>Image</dt> + <dd> + <a href={link.href.toURL.toExternalForm}> + <img src={link.href.toURL.toExternalForm} alt={name.getOrElse("")} title={name.getOrElse("")}/> + </a> + </dd> + case _ => + NodeSeq.Empty + }} + </dl> + </xml:group> + } + + def meta(implicit cj: JsonCollection) = <xml:group> + <div class="row-fluid"> + <div class="span12"> + <dl> + <dt>version</dt> + <dd>{cj.version.name}</dd> + <dt>href</dt> + <dd> + <div>{href(cj.href)}</div> + </dd> + </dl> + </div> + </div> + <div class="row-fluid"> + <div class="span12"> + <p> + <a class="btn btn-primary" href={render(cj.href)}>Explore</a> + <a class="btn btn-primary" href={cj.href.toURL.toExternalForm}>Raw</a> + <a class="btn btn-danger" href={delete(cj.href)}>Delete</a> + <form action='http://redbot.org'> + <input name='uri' value={url.toURL.toExternalForm} type='hidden'/> + <input name='req_hdr' value='Accept: application/vnd.collection+json' type='hidden'/> + <button class='btn btn-primary' type='submit'>Check with redbot.org</button> + </form> + </p> + </div> + </div> + {cj.links match { + case Nil => + NodeSeq.Empty + case _ => + <xml:group> + <h2>Collection Links</h2> + {cj.links.zipWithIndex.map { case (l, i) => + val name = Json4sHelpers.getAsString(l.underlying, "name") + val title = l.prompt.orElse(name) match { + case Some(t) => ": " + t + case _ => "" + } + Group(Seq(<h3 id={"link-#" + (i + 1)}>{"Collection link #" + (i + 1) + title}</h3>, link(l))) + }} + </xml:group> + }} + </xml:group> + + // TODO: If the collection has prev/next links, add buttons to automaticaly navigate those. + // TODO: Add ability to show the raw part of the collection. + def items(cj: JsonCollection) = { + + def itemLinks(cj: JsonCollection) = { + val first = cj.findLinkByRel("first") + val prev = cj.findLinkByRel("prev") + val next = cj.findLinkByRel("next") + val last = cj.findLinkByRel("last") + if(first.isDefined || prev.isDefined || next.isDefined || last.isDefined) { + <div class="fluid-row"> + <div class="span12"> + {if(first.isDefined) <a class="btn btn-primary btn-mini" href={render(first.get.href) + "#items"}>First</a>} + {if(prev.isDefined) <a class="btn btn-primary btn-mini" href={render(prev.get.href) + "#items"}>Previous</a>} + {if(next.isDefined) <a class="btn btn-primary btn-mini" href={render(next.get.href) + "#items"}>Next</a>} + {if(last.isDefined) <a class="btn btn-primary btn-mini" href={render(last.get.href) + "#items"}>Last</a>} + </div> + </div> + } + } + + <xml:group> + {itemLinks(cj)} + { + cj.items.zipWithIndex.map { case (item, i) => + val links = item.links + <div class="item-container"> + <h2 id={"item-" + (i + 1)}>Item #{i + 1}</h2> + <div class="fluid-row"> + <div class="span12"> + <p> + <a class="btn btn-primary btn-mini" href={render(item.href)}>Explore</a> + <a class="btn btn-primary btn-mini" href={item.href.toURL.toExternalForm}>Raw</a> + <a class="btn btn-primary btn-mini" onClick='var item = $(this).parentsUntil("#items").last(); item.find(".item-form").toggle(); item.find(".item-data").toggle()'>Edit</a> + <a class="btn btn-danger btn-mini" href={delete(item.href)}>Delete</a> + </p> + </div> + </div> + <div class="fluid-row"> + <div class="span12"> + <dl> + <dt>href</dt> + <dd><div>{href(item.href)}</div></dd> + </dl> + </div> + </div> + {if(links.nonEmpty) { + <xml:group> + <h3>Item Links</h3> + {links.zipWithIndex.map { case (l, i2) => + Group(Seq(<h4>Item Link #{i2 + 1}</h4>, link(l))) + }} + </xml:group> + }} + <h3 class="item-data">Data</h3> + <div class="item-data fluid-row"> + <div class="span12"> + <table class="data-table"> + <!-- d.value is not the correct way to access the value --> + {item.data map { d => <tr><th>{d.name}</th><td>{d.value}</td></tr>}} + </table> + </div> + </div> + <h3 class="item-data" style="display: none">Data</h3> + <div class="item-data fluid-row" style="display: none"> + <div class="span12"> + <form class="well" action="/write" method="POST"> + <input type="hidden" name="url" value={item.href.toURL.toExternalForm}/> + <table class="cj-form"> + <tbody>{ + item.data map { d => + val value = d.value.toString + <tr> + <th title={"name: " + d.name}> + <div> + <label for={d.name}>{d.prompt.getOrElse(d.name)}</label> + </div> + </th> + <td> + <input id={d.name} type="text" name={"param-" + d.name} value={value}/> + </td> + </tr> + } + } + </tbody> + <tfoot> + <tr> + <th></th> + <td> + <p> + <input class="btn btn-primary" type="submit">Update</input> + </p> + </td> + </tr> + </tfoot> + </table> + </form> + </div> + </div> + </div> + } + } + {itemLinks(cj)} + </xml:group> + } + + def queries(implicit cj: JsonCollection) = { + {cj.queries.zipWithIndex map { case (query, i) => + val prompt = Json4sHelpers.getAsString(query.underlying, "prompt") + val name = Json4sHelpers.getAsString(query.underlying, "name") + val title = prompt.orElse(name).getOrElse("Unnamed query #" + (i + 1)) + + <h2 id={"query-" + (i + 1)}>{title}</h2> + <div class="row-fluid"> + <div class="span12"> + <form class="well" action="/render"> + <input type="hidden" name="url" value={query.href.toURL.toExternalForm}/> + <table class="cj-form"> + <tbody>{query.data map { d: Property => + val value = params(d.name).headOption.getOrElse(d.value.toString) + <tr> + <th title={"name: " + d.name}> + <div> + <label for={d.name}>{d.prompt.getOrElse(d.name)}</label> + </div> + </th> + <td> + <input id={d.name} type="text" name={"param-" + d.name} value={value}/> + </td> + </tr> + }}</tbody> + <tfoot> + <tr> + <th></th> + <td> + <input class="btn btn-primary" type="submit" value="Query"/> + </td> + </tr> + </tfoot> + </table> + </form> + </div> + </div> + }} + } + + def template(implicit cj: JsonCollection) = + <div class="row-fluid"> + <div class="span12"> + <p>The data will be submitted to {href(cj.href)}</p> + <form class="well" action="/write" method="POST"> + <input type="hidden" name="url" value={cj.href.toURL.toExternalForm}/> + <table class="cj-form"> + <tbody>{cj.template.get.data map { d => + val value = params(d.name).headOption.getOrElse(d.value.toString) + <tr> + <th title={"name='" + d.name + "'"}> + <div> + <label for={d.name}>{d.prompt.getOrElse(d.name)}</label> + </div> + </th> + <td> + <input id={d.name} type="text" name={"param-" + d.name} value={value}/> + </td> + </tr> + }}</tbody> + <tfoot> + <tr> + <th></th> + <td> + <!-- + input.btn.btn-primary.disabled(type='submit', disabled) Write + p.help-block This collection has a template, but doesn't have a href which is required. + --> + <input class="btn btn-primary" type="submit" value="Write"/> + </td> + </tr> + </tfoot> + </table> + </form> + </div> + </div> + +/* +block error + div(class='row-fluid') + dl + dt title + dd + if collection.error.title + | #{collection.error.title} + else + i Not set + dt code + dd + if collection.error.code + | #{collection.error.code} + else + i Not set + dt message + dd + if collection.error.message + - var lines = collection.error.message.split('\n') + if lines.length > 1 + for line in lines + | #{line.replace(/ /g, ' ')} + br + else + | #{collection.error.message} + else + i Not set +*/ + def error(implicit cj: JsonCollection) = { + val e = cj.error.get + val message = e.message map { m => + val lines = m.split('\n') + lines.map(s => scala.xml.Text(s): NodeSeq).reduce(_ ++ <br /> ++ _) + } + + <div class="row-fluid"> + <dl> + <dt>title</dt> + <dd>{Option(e.title).filter(!_.isEmpty).getOrElse(notSet)}</dd> + <dt>code</dt> + <dd>{e.code.getOrElse(notSet)}</dd> + <dt>message</dt> + <dd>{message.getOrElse("")}</dd> + </dl> + </div> + } + + def httpResponse = { + <div class="row-fluid"> + <dl> + <dt>Request URL</dt> + <dd><a href={render(url)}>{url}</a></dd> + </dl> + <table> + <tr> + <td colspan="2">{res.code} {res.status}</td> + </tr> + {res.headers map { case (header, values) => { values map { value => + <tr> + <td><tt>{header}</tt></td> + <td><tt>{tryLink(value)}</tt></td> + </tr> + }}}} + </table> + </div> + } + + def parsedContent(implicit cj: JsonCollection) = <xml:group> + <section id="meta"> + <div class="page-header"> + <h1>Meta</h1> + </div> + {meta} + </section> + {if(cj.items.nonEmpty) { + <section id="items"> + <div class="page-header"> + <h1> + Items + {if(cj.items.length > 1) { + <span class="badge"> + {cj.items.length} + {if(cj.findLinkByRel("next").isDefined) { "+" } } + </span> + }} + </h1> + </div> + {items(cj)} + </section> + }} + {if(cj.queries.nonEmpty) { + <section id="queries"> + <div class="page-header"><h1>Queries</h1></div> + {queries} + </section> + }} + {if(cj.template.isDefined) { + <section id="template"> + <div class="page-header"><h1>Template</h1></div> + {template} + </section> + }} + {if(cj.error.isDefined) { + <section id="error"> + <div class="page-header"><h1>Error</h1></div> + {error} + </section> + }} + </xml:group> + + def innerContent = <xml:group> + {result match { + case Left(ex) => + <section id="server-error"> + <div class="page-header"><h1>Server Error</h1></div> + <pre>{getStackTrace(ex)}</pre> + </section> + case Right(cj) => <xml:group> + {try { parsedContent(cj) } catch { case ex: Exception => <div>Unable to process model: {ex.getMessage}</div> }} + <section id="formatted-body"> + <div class="page-header"><h1>Formatted Body</h1></div> + <div class="row-fluid"> + <div class="span12"> + <pre>{JsonMethods.pretty(JsonMethods.render(cj.underlying))}</pre> + </div> + </div> + </section> + </xml:group> + }} + <section id="http-response"> + <div class="page-header"><h1>HTTP Response</h1></div> + {httpResponse} + </section> + </xml:group> + + def sidebar = <div id="navbar" class="sidebar-nav sidebar-nav-fixed"> + <ul class="nav nav-list"> + {result match { + case Left(_) => + <li class="nav-header"><a href="#server-error">Server Error</a></li> + case Right(cj) => + {try { + <xml:group> + <li class="nav-header active"><a href="#meta">Meta</a></li> + {cj.links.zipWithIndex map { case (l, i) => + <li><a href={"#link-" + (i + 1)}>{getName(l, "Link", i + 1)}</a></li> + }} + {if(cj.items.nonEmpty) { + <xml:group> + <li class="nav-header"><a href="#items">Items</a></li> + {cj.items.zipWithIndex.map { case (_, i) => + <li><a href={"#item-" + (i + 1)}>#{i + 1}</a></li> + }} + </xml:group> + }} + {if(cj.queries.nonEmpty) { + <xml:group> + <li class="nav-header"><a href="#queries">Queries</a></li> + {cj.queries.zipWithIndex.map { case (_, i) => + <li><a href={"#query-" + (i + 1)}>#{i + 1}</a></li> + }} + </xml:group> + }} + {cj.template map { _ => <li class="nav-header"><a href="#template">Template</a></li> } getOrElse NodeSeq.Empty } + {cj.error map { _ => <li class="nav-header"><a href="#error">Error</a></li> } getOrElse NodeSeq.Empty } + <li class="nav-header"><a href="#formatted-body">Formatted Body</a></li> + <li class="nav-header"><a href="#http-response">HTTP Response</a></li> + </xml:group> + } catch { case ex: Exception => <div>Unable to process model: {ex.getMessage}</div> }} + }} + </ul> + </div> + + def content = + <div class="row-fluid"> + <div class="span3">{sidebar}</div> + <div class="span9">{innerContent}</div> + </div> + + layout(content, None) + } + + def layout(content: Elem, headSnippet: Option[String]) = + <html> + <head> + <title>Collection+JSON Explorer</title> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <link href="/bootstrap-2.0.4/css/bootstrap.css" rel="stylesheet"/> + <link href="/bootstrap-2.0.4/css/bootstrap-responsive.css" rel="stylesheet"/> + <link href="/stylesheets/style.css" rel="stylesheet"/> + <link href="/bootstrap-2.0.4/css/bootstrap-responsive.css" rel="stylesheet"/> + <!--[if lt IE 9]><script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script><![endif]--> + <script> + {headSnippet.getOrElse("")} + </script> + </head> + + <body data-spy="scroll"> + <div class="container-fluid"> + {content} + </div> + + <script src="/javascripts/jquery-1.7.2.min.js"></script> + <script src="/javascripts/gui.js"></script> + <script src="/bootstrap-2.0.4/js/bootstrap.min.js"></script> + </body> + </html> +} |