Add initial url parsing and pattern matching

Credit to http4s everywhere.
This commit is contained in:
Zak Patterson 2019-02-01 11:21:07 -05:00
parent 29a7c572ba
commit 8ddf2bcdc1
13 changed files with 706 additions and 0 deletions

142
.gitignore vendored Normal file
View File

@ -0,0 +1,142 @@
# Created by https://www.gitignore.io/api/sbt,scala,bloop,metals,intellij,sublimetext
# Edit at https://www.gitignore.io/?templates=sbt,scala,bloop,metals,intellij,sublimetext
### Bloop ###
.bloop/
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
.idea/sonarlint
### Metals ###
.metals/
### SBT ###
# Simple Build Tool
# http://www.scala-sbt.org/release/docs/Getting-Started/Directories.html#configuring-version-control
dist/*
target/
lib_managed/
src_managed/
project/boot/
project/plugins/project/
.history
.cache
.lib/
### Scala ###
*.class
*.log
### SublimeText ###
# Cache files for Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
# Workspace files are user-specific
*.sublime-workspace
# Project files should be checked into the repository, unless a significant
# proportion of contributors will probably not be using Sublime Text
# *.sublime-project
# SFTP configuration file
sftp-config.json
# Package control specific files
Package Control.last-run
Package Control.ca-list
Package Control.ca-bundle
Package Control.system-ca-bundle
Package Control.cache/
Package Control.ca-certs/
Package Control.merged-ca-bundle
Package Control.user-ca-bundle
oscrypto-ca-bundle.crt
bh_unicode_properties.cache
# Sublime-github package stores a github token in this file
# https://packagecontrol.io/packages/sublime-github
GitHub.sublime-settings
# End of https://www.gitignore.io/api/sbt,scala,bloop,metals,intellij,sublimetext
/.idea/

23
.scalafmt.conf Normal file
View File

@ -0,0 +1,23 @@
style = default
maxColumn = 100
// Vertical alignment is pretty, but leads to bigger diffs
align = none
// Insist on trailing commas for better difs in element construction
trailingCommas = always
danglingParentheses = true
rewrite.rules = [
AvoidInfix
RedundantBraces
RedundantParens
AsciiSortImports
PreferCurlyFors
]
project.excludeFilters = [
"scalafix-inputs",
"scalafix-outputs"
]

33
.travis.yml Normal file
View File

@ -0,0 +1,33 @@
language: scala
install:
- rvm use 2.3.0 --install --fuzzy
- gem update --system
- gem install sass
- gem install jekyll -v 3.2.1
scala:
- 2.12.8
jobs:
include:
- stage: verify
script:
- sbt ++$TRAVIS_SCALA_VERSION todomvc/fullOptJS::webpack
- sbt docs/mdoc
- sbt docs/makeMicrosite
- mkdir -p router-docs/site/todomvc
- cp -R router-docs/target/site/* ./router-docs/site/
- cp ./router-docs/site/Readme.html ./router-docs/site/index.html
deploy:
provider: pages
skip-cleanup: true
github-token: $GITHUB_TOKEN # Set in the settings page of your repository, as a secure variable
keep-history: true
local-dir: router-docs/site
target-branch: gh-pages
on:
branch: master

8
README.md Normal file
View File

@ -0,0 +1,8 @@
outwatch-router
===
Easy routing for [outwatch](https://outwatch.github.io) on scala.js
Most of this code is adapted from [http4s](http4s.org)'s route parsing and path pattern matching.
See [documentation](https://clovellytech.github.io/outwatch-router/index.html)

77
build.sbt Normal file
View File

@ -0,0 +1,77 @@
import dependencies._
cancelable in Global := true
val commonSettings = Seq(
organization := "com.clovellytech",
version := Version.version,
scalaVersion := Version.scalaVersion,
resolvers ++= addResolvers,
scalacOptions ++= options.scalac,
scalacOptions in (Compile, console) := options.scalacConsole,
updateOptions := updateOptions.value.withLatestSnapshots(false)
) ++ compilerPlugins
val withTests : String = "compile->compile;test->test"
val testOnly : String = "test->test"
lazy val docs = (project in file("./router-docs"))
.settings(name := "outwatch-router-docs")
.enablePlugins(MdocPlugin)
.settings(commonSettings)
.dependsOn(router)
lazy val copyFastOptJS = TaskKey[Unit]("copyFastOptJS", "Copy javascript files to target directory")
lazy val router = (project in file("./outwatch-router"))
.settings(name := "outwatch-router")
.enablePlugins(ScalaJSPlugin)
.enablePlugins(ScalaJSBundlerPlugin)
.settings(commonSettings)
.settings(
scalaJSModuleKind := ModuleKind.CommonJSModule,
scalacOptions += "-P:scalajs:sjsDefinedByDefault",
useYarn := true, // makes scalajs-bundler use yarn instead of npm
jsEnv in Test := new org.scalajs.jsenv.jsdomnodejs.JSDOMNodeJSEnv,
scalaJSUseMainModuleInitializer := true,
scalaJSModuleKind := ModuleKind.CommonJSModule, // configure Scala.js to emit a JavaScript module instead of a top-level script
version in webpack := "4.16.1",
version in startWebpackDevServer := "3.1.4",
webpackDevServerExtraArgs := Seq("--progress", "--color"),
webpackConfigFile in fastOptJS := Some(baseDirectory.value / "webpack.config.dev.js"),
// https://scalacenter.github.io/scalajs-bundler/cookbook.html#performance
webpackBundlingMode in fastOptJS := BundlingMode.LibraryOnly(),
resolvers += "jitpack" at "https://jitpack.io",
libraryDependencies ++= Seq(
"io.github.outwatch" % "outwatch" % "ea240c6d04",
"org.http4s" %% "parboiled" % "1.0.0",
"org.scalatest" %%% "scalatest" % "3.0.5" % Test
),
copyFastOptJS := {
val inDir = (crossTarget in (Compile, fastOptJS)).value
val outDir = (crossTarget in (Compile, fastOptJS)).value / "dev"
val files = Seq("outwatch-router-fastopt-loader.js", "outwatch-router-frontend-fastopt.js", "outwatch-router-frontend-fastopt.js.map") map { p => (inDir / p, outDir / p) }
IO.copy(files, overwrite = true, preserveLastModified = true, preserveExecutable = true)
},
// hot reloading configuration:
// https://github.com/scalacenter/scalajs-bundler/issues/180
addCommandAlias("dev", "; compile; fastOptJS::startWebpackDevServer; devwatch; fastOptJS::stopWebpackDevServer"),
addCommandAlias("devwatch", "~; fastOptJS; copyFastOptJS")
)
lazy val exampleApp = (project in file("router-example"))
.settings(name := "outwatch-example")
.settings(commonSettings)
.dependsOn(router)
lazy val root = (project in file("."))
.settings(name := "outwatch-router-root")
.settings(commonSettings)
.settings(
skip in publish := true,
aggregate in reStart := false,
)
.dependsOn(router)
.aggregate(router)

43
docs/Intro.md Normal file
View File

@ -0,0 +1,43 @@
Outwatch Router
===
```scala mdoc
import cats._
import cats.implicits._
import cats.Applicative
import cats.data.Kleisli
import outwatch.router._, Router._
sealed abstract class Page
case class RootPage() extends Page
case class Login() extends Page
case class Register() extends Page
case class Profile(userId: String) extends Page
case class NotFound() extends Page
object Page{
def root: Page = RootPage()
def login: Page = Login()
def register: Page = Register()
def profile(userId: String): Page = Profile(userId)
def notFound: Page = NotFound()
}
def routes[F[_]: Applicative]: AppRouter[F, Page] = Kleisli[F, Path, Page] {
case Root => Page.root.pure[F]
case Root / "login" => Page.login.pure[F]
case Root / "register" => Page.register.pure[F]
case Root / "profile" / userId => Page.profile(userId).pure[F]
case _ => Page.notFound.pure[F]
}
val router = routes[Id]
router.run(Root)
router.run(Root / "login")
router.run(Root / "profile" / "saopa98f")
router.run(Path("/profile/asd"))
router.run(Path("/apsinoasn"))
```

View File

@ -0,0 +1,220 @@
// Completely copied from org.http4s.dsl.impl
package outwatch.router
import cats.implicits._
import java.nio.{ByteBuffer, CharBuffer}
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets.UTF_8
/** Base class for path extractors. */
trait Path {
def /(child: String) = new /(this, child)
def toList: List[String]
def parent: Path
def lastOption: Option[String]
def startsWith(other: Path): Boolean
}
object Path {
/** Constructs a path from a single string by splitting on the `'/'`
* character.
*
* Leading slashes do not create an empty path segment. This is to
* reflect that there is no distinction between a request to
* `http://www.example.com` from `http://www.example.com/`.
*
* Trailing slashes result in a path with an empty final segment,
* unless the path is `"/"`, which is `Root`.
*
* Segments are URL decoded.
*
* {{{
* scala> Path("").toList
* res0: List[String] = List()
* scala> Path("/").toList
* res1: List[String] = List()
* scala> Path("a").toList
* res2: List[String] = List(a)
* scala> Path("/a").toList
* res3: List[String] = List(a)
* scala> Path("/a/").toList
* res4: List[String] = List(a, "")
* scala> Path("//a").toList
* res5: List[String] = List("", a)
* scala> Path("/%2F").toList
* res0: List[String] = List(/)
* }}}
*/
def apply(str: String): Path =
if (str == "" || str == "/")
Root
else {
val segments = str.split("/", -1)
// .head is safe because split always returns non-empty array
val segments0 = if (segments.head == "") segments.drop(1) else segments
segments0.foldLeft(Root: Path)((path, seg) => path / UrlCodingUtils.urlDecode(seg))
}
def apply(first: String, rest: String*): Path =
rest.foldLeft(Root / first)(_ / _)
def apply(list: List[String]): Path =
list.foldLeft(Root: Path)(_ / _)
def unapplySeq(path: Path): Some[List[String]] =
Some(path.toList)
//
// def unapplySeq[F[_]](request: Request[F]): Some[List[String]] =
// Some(Path(request.pathInfo).toList)
}
final case class /(parent: Path, child: String) extends Path {
lazy val toList: List[String] = parent.toList ++ List(child)
def lastOption: Some[String] = Some(child)
lazy val asString: String = s"$parent/${UrlCodingUtils.pathEncode(child)}"
override def toString: String = asString
def startsWith(other: Path): Boolean = {
val components = other.toList
toList.take(components.length) === components
}
}
/**
* Root extractor:
* {{{
* Path("/") match {
* case Root => ...
* }
* }}}
*/
case object Root extends Path {
def toList: List[String] = Nil
def parent: Path = this
def lastOption: None.type = None
override def toString = ""
def startsWith(other: Path): Boolean = other == Root
}
private[router] object UrlCodingUtils {
private val lower = ('a' to 'z').toSet
private val upper = ('A' to 'Z').toSet
private val num = ('0' to '9').toSet
val Unreserved: Set[Char] = lower ++ upper ++ num ++ "-_.~"
private val toSkip : Set[Char] = Unreserved ++ "!$&'()*+,;=:/?@"
private val HexUpperCaseChars: Array[Char] = ('A' to 'F').toArray
/**
* Percent-encodes a string. Depending on the parameters, this method is
* appropriate for URI or URL form encoding. Any resulting percent-encodings
* are normalized to uppercase.
*
* @param toEncode the string to encode
* @param charset the charset to use for characters that are percent encoded
* @param spaceIsPlus if space is not skipped, determines whether it will be
* rendreed as a `"+"` or a percent-encoding according to `charset`.
* @param toSkip a predicate of characters exempt from encoding. In typical
* use, this is composed of all Unreserved URI characters and sometimes a
* subset of Reserved URI characters.
*/
def urlEncode(
toEncode: String,
charset: Charset = UTF_8,
spaceIsPlus: Boolean = false,
toSkip: Char => Boolean = toSkip): String = {
val in = charset.encode(toEncode)
val out = CharBuffer.allocate((in.remaining() * 3).toInt)
while (in.hasRemaining) {
val c = in.get().toChar
if (toSkip(c)) {
out.put(c)
} else if (c == ' ' && spaceIsPlus) {
out.put('+')
} else {
out.put('%')
out.put(HexUpperCaseChars((c >> 4) & 0xF))
out.put(HexUpperCaseChars(c & 0xF))
}
}
out.flip()
out.toString
}
private val SkipEncodeInPath =
Unreserved ++ ":@!$&'()*+,;="
def pathEncode(s: String, charset: Charset = UTF_8): String =
UrlCodingUtils.urlEncode(s, charset, false, SkipEncodeInPath)
/**
* Percent-decodes a string.
*
* @param toDecode the string to decode
* @param charset the charset of percent-encoded characters
* @param plusIsSpace true if `'+'` is to be interpreted as a `' '`
* @param toSkip a predicate of characters whose percent-encoded form
* is left percent-encoded. Almost certainly should be left empty.
*/
def urlDecode(
toDecode: String,
charset: Charset = UTF_8,
plusIsSpace: Boolean = false,
toSkip: Char => Boolean = Function.const(false)): String = {
val in = CharBuffer.wrap(toDecode)
// reserve enough space for 3-byte UTF-8 characters. 4-byte characters are represented
// as surrogate pairs of characters, and will get a luxurious 6 bytes of space.
val out = ByteBuffer.allocate(in.remaining() * 3)
while (in.hasRemaining) {
val mark = in.position()
val c = in.get()
if (c == '%') {
if (in.remaining() >= 2) {
val xc = in.get()
val yc = in.get()
// scalastyle:off magic.number
val x = Character.digit(xc, 0x10)
val y = Character.digit(yc, 0x10)
// scalastyle:on magic.number
if (x != -1 && y != -1) {
val oo = (x << 4) + y
if (!toSkip(oo.toChar)) {
out.put(oo.toByte)
} else {
out.put('%'.toByte)
out.put(xc.toByte)
out.put(yc.toByte)
}
} else {
out.put('%'.toByte)
in.position(mark + 1)
}
} else {
// This is an invalid encoding. Fail gracefully by treating the '%' as
// a literal.
out.put(c.toByte)
while (in.hasRemaining) out.put(in.get().toByte)
}
} else if (c == '+' && plusIsSpace) {
out.put(' '.toByte)
} else {
// normally `out.put(c.toByte)` would be enough since the url is %-encoded,
// however there are cases where a string can be partially decoded
// so we have to make sure the non us-ascii chars get preserved properly.
if (this.toSkip(c)) {
out.put(c.toByte)
} else {
out.put(charset.encode(String.valueOf(c)))
}
}
}
out.flip()
charset.decode(out).toString
}
}

View File

@ -0,0 +1,9 @@
package outwatch.router
import cats.data.Kleisli
object Router {
type AppRouter[F[_], A] = Kleisli[F, Path, A]
val AppRouter = Kleisli
}

View File

@ -0,0 +1,84 @@
import sbt._
import sbt.librarymanagement.DependencyBuilders
import org.portablescala.sbtplatformdeps._
object dependencies {
val addResolvers = Seq(
"52north for postgis" at "http://52north.org/maven/repo/releases/",
Resolver.sonatypeRepo("releases"),
Resolver.sonatypeRepo("snapshots")
)
val compilerPlugins = Seq(
addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.9"),
addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full)
)
val bcrypt = "3.1"
val cats = "1.4.0"
val catsMtl = "0.4.0"
val catsEffect = "1.2.0"
val circe = "0.11.0"
val circeConfig = "0.6.1"
val doobie = "0.6.0"
val flyway = "5.2.4"
val fs2 = "1.0.2"
val h4sm = "0.0.17"
val http4s = "0.20.0-M5"
val logback = "1.2.3"
val monocle = "1.5.0"
val postgis = "1.3.3"
val postgres = "42.2.5"
val scalaCheck = "1.14.0"
val scalaTest = "3.0.5"
val simulacrum = "0.14.0"
val httpDeps = Seq(
"http4s-blaze-server",
"http4s-blaze-client",
"http4s-circe",
"http4s-dsl"
).map("org.http4s" %% _ % http4s) ++ Seq(
"circe-core",
"circe-generic",
"circe-parser",
"circe-java8"
).map("io.circe" %% _ % circe)
val testDeps = Seq(
"org.scalatest" %% "scalatest" % scalaTest,
"org.tpolecat" %% "doobie-scalatest" % doobie,
"org.scalacheck" %% "scalacheck" % scalaCheck,
"com.clovellytech" %% "h4sm-dbtesting" % h4sm
)
val testDepsInTestOnly = testDeps.map(_ % "test")
val dbDeps = Seq(
"org.flywaydb" % "flyway-core" % flyway,
"org.postgresql" % "postgresql" % postgres,
"org.postgis" % "postgis-jdbc" % postgis
) ++ Seq(
"doobie-core",
"doobie-postgres",
"doobie-hikari"
).map("org.tpolecat" %% _ % doobie)
val commonDeps = Seq(
"io.circe" %% "circe-config" % circeConfig,
"ch.qos.logback" % "logback-classic" % logback,
"com.github.mpilquist" %% "simulacrum" % simulacrum
) ++ Seq(
"h4sm-auth",
"h4sm-files",
"h4sm-permissions"
).map("com.clovellytech" %% _ % h4sm) ++ Seq(
"monocle-core",
"monocle-generic",
"monocle-macro",
"monocle-state",
"monocle-refined"
).map("com.github.julien-truffaut" %% _ % monocle)
val allDeps = httpDeps ++ dbDeps ++ commonDeps ++ testDepsInTestOnly
}

58
project/Options.scala Normal file
View File

@ -0,0 +1,58 @@
object options {
val scalac = Seq(
// format: off
"-deprecation", // Emit warning and location for usages of deprecated APIs.
"-encoding", "utf-8", // Specify character encoding used by source files.
"-explaintypes", // Explain type errors in more detail.
"-feature", // Emit warning and location for usages of features that should be imported explicitly.
"-language:existentials", // Existential types (besides wildcard types) can be written and inferred
"-language:experimental.macros", // Allow macro definition (besides implementation and application)
"-language:higherKinds", // Allow higher-kinded types
"-language:implicitConversions", // Allow definition of implicit functions called views
"-unchecked", // Enable additional warnings where generated code depends on assumptions.
"-Xcheckinit", // Wrap field accessors to throw an exception on uninitialized access.
"-Xfatal-warnings", // Fail the compilation if there are any warnings.
"-Xfuture", // Turn on future language features.
"-Xlint:adapted-args", // Warn if an argument list is modified to match the receiver.
"-Xlint:by-name-right-associative", // By-name parameter of right associative operator.
"-Xlint:constant", // Evaluation of a constant arithmetic expression results in an error.
"-Xlint:delayedinit-select", // Selecting member of DelayedInit.
"-Xlint:doc-detached", // A Scaladoc comment appears to be detached from its element.
"-Xlint:inaccessible", // Warn about inaccessible types in method signatures.
"-Xlint:infer-any", // Warn when a type argument is inferred to be `Any`.
"-Xlint:missing-interpolator", // A string literal appears to be missing an interpolator id.
"-Xlint:nullary-override", // Warn when non-nullary `def f()' overrides nullary `def f'.
"-Xlint:nullary-unit", // Warn when nullary methods return Unit.
"-Xlint:option-implicit", // Option.apply used implicit view.
"-Xlint:package-object-classes", // Class or object defined in package object.
"-Xlint:poly-implicit-overload", // Parameterized overloaded implicit methods are not visible as view bounds.
"-Xlint:private-shadow", // A private field (or class parameter) shadows a superclass field.
"-Xlint:stars-align", // Pattern sequence wildcard must align with sequence component.
"-Xlint:type-parameter-shadow", // A local type parameter shadows a type already in scope.
"-Xlint:unsound-match", // Pattern match may not be typesafe.
"-Yno-adapted-args", // Do not adapt an argument list (either by inserting () or creating a tuple) to match the receiver.
"-Ypartial-unification", // Enable partial unification in type constructor inference
"-Ywarn-dead-code", // Warn when dead code is identified.
"-Ywarn-extra-implicit", // Warn when more than one implicit parameter section is defined.
"-Ywarn-inaccessible", // Warn about inaccessible types in method signatures.
"-Ywarn-infer-any", // Warn when a type argument is inferred to be `Any`.
"-Ywarn-nullary-unit", // Warn when nullary methods return Unit.
"-Ywarn-nullary-override", // Warn when non-nullary `def f()' overrides nullary `def f'.
"-Ywarn-numeric-widen", // Warn when numerics are widened.
"-Ywarn-unused:implicits", // Warn if an implicit parameter is unused.
"-Ywarn-unused:imports", // Warn if an import selector is not referenced.
"-Ywarn-unused:locals", // Warn if a local definition is unused.
"-Ywarn-unused:params", // Warn if a value parameter is unused.
"-Ywarn-unused:patvars", // Warn if a variable bound in a pattern is unused.
"-Ywarn-unused:privates", // Warn if a private member is unused.
"-Ywarn-value-discard", // Warn when non-Unit expression results are unused.
"-Yrangepos"
// format: on
)
val badScalacConsoleFlags = Seq("-Xfatal-warnings", "-Ywarn-unused:imports")
val scalacConsole = scalac.filterNot(badScalacConsoleFlags.contains(_))
}

4
project/Version.scala Normal file
View File

@ -0,0 +1,4 @@
object Version{
val version = "0.0.1"
val scalaVersion = "2.12.8"
}

1
project/build.properties Normal file
View File

@ -0,0 +1 @@
sbt.version=1.2.3

4
project/plugins.sbt Normal file
View File

@ -0,0 +1,4 @@
addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.13.1")
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.26")
addSbtPlugin("org.scalameta" % "sbt-mdoc" % "1.2.8" )
addSbtPlugin("com.47deg" % "sbt-microsites" % "0.8.0")