├── .codecov.yml ├── .git-blame-ignore-revs ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .scala-steward.conf ├── .scalafix.conf ├── .scalafmt.conf ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── argonaut └── src │ ├── main │ └── scala │ │ └── io │ │ └── finch │ │ └── argonaut │ │ ├── Decoders.scala │ │ ├── Encoders.scala │ │ └── package.scala │ └── test │ └── scala │ └── io │ └── finch │ └── argonaut │ └── ArgonautSpec.scala ├── benchmarks └── src │ └── main │ └── scala │ └── io │ └── finch │ ├── benchmarks.scala │ └── data │ └── Foo.scala ├── build.sbt ├── build ├── build.sh ├── credentials.sbt.enc ├── deploy_key.pem.enc ├── pubring.gpg.enc └── secring.gpg.enc ├── circe └── src │ ├── main │ └── scala │ │ └── io │ │ └── finch │ │ └── circe │ │ ├── AccumulatingDecoders.scala │ │ ├── CirceError.scala │ │ ├── Decoders.scala │ │ ├── Encoders.scala │ │ └── package.scala │ └── test │ └── scala │ └── io │ └── finch │ └── circe │ └── test │ └── CirceSpec.scala ├── core └── src │ ├── main │ └── scala │ │ └── io │ │ └── finch │ │ ├── Accept.scala │ │ ├── Bootstrap.scala │ │ ├── Compile.scala │ │ ├── Decode.scala │ │ ├── DecodeEntity.scala │ │ ├── DecodePath.scala │ │ ├── DecodeStream.scala │ │ ├── Encode.scala │ │ ├── EncodeStream.scala │ │ ├── Endpoint.scala │ │ ├── EndpointModule.scala │ │ ├── EndpointResult.scala │ │ ├── Error.scala │ │ ├── Input.scala │ │ ├── LiftReader.scala │ │ ├── LowPriorityEndpointInstances.scala │ │ ├── Output.scala │ │ ├── Outputs.scala │ │ ├── ServerSentEvent.scala │ │ ├── ToResponse.scala │ │ ├── ToService.scala │ │ ├── Trace.scala │ │ ├── contentTypes.scala │ │ ├── endpoint │ │ ├── body.scala │ │ ├── cookie.scala │ │ ├── endpoint.scala │ │ ├── header.scala │ │ ├── method.scala │ │ ├── multipart.scala │ │ ├── param.scala │ │ └── path.scala │ │ ├── internal │ │ ├── DummyExecutionContext.scala │ │ ├── Mapper.scala │ │ ├── PairJoin.scala │ │ ├── ParseNumber.scala │ │ ├── currentTime.scala │ │ ├── newLine.scala │ │ └── package.scala │ │ └── package.scala │ └── test │ ├── resources │ └── test.txt │ └── scala │ └── io │ └── finch │ ├── BodySpec.scala │ ├── BootstrapSpec.scala │ ├── DecodeEntityLaws.scala │ ├── DecodeEntitySpec.scala │ ├── DecodePathLaws.scala │ ├── DecodePathSpec.scala │ ├── Dispatchers.scala │ ├── EncodeLaws.scala │ ├── EncodeSpec.scala │ ├── EndToEndSpec.scala │ ├── EndpointSpec.scala │ ├── EntityEndpointLaws.scala │ ├── EvaluatingEndpointLaws.scala │ ├── ExtractPathLaws.scala │ ├── FinchSpec.scala │ ├── HeaderSpec.scala │ ├── InputSpec.scala │ ├── MethodSpec.scala │ ├── MultipartSpec.scala │ ├── OutputSpec.scala │ ├── ParamSpec.scala │ ├── ServerSentEventSpec.scala │ ├── StreamingLaws.scala │ ├── TestInstances.scala │ ├── TraceSpec.scala │ ├── data │ └── Foo.scala │ └── internal │ ├── HttpContentSpec.scala │ ├── HttpMessageSpec.scala │ └── TooFastStringSpec.scala ├── docs ├── mdoc │ ├── best-practices.md │ ├── contributing.md │ ├── cookbook.md │ ├── index.md │ └── user-guide.md └── src │ └── main │ └── resources │ ├── microsite │ ├── css │ │ └── override.css │ ├── data │ │ └── menu.yml │ └── img │ │ ├── favicon.png │ │ ├── jumbotron_pattern.png │ │ ├── navbar_brand.png │ │ ├── navbar_brand2x.png │ │ ├── navbar_brand_favicon.png │ │ ├── sidebar_brand.png │ │ └── sidebar_brand2x.png │ └── rootdoc.txt ├── examples └── src │ ├── main │ ├── resources │ │ └── todo │ │ │ ├── index.html │ │ │ └── main.js │ └── scala │ │ └── io │ │ └── finch │ │ ├── div │ │ └── Main.scala │ │ ├── iteratee │ │ └── Main.scala │ │ ├── middleware │ │ └── Main.scala │ │ ├── todo │ │ ├── App.scala │ │ ├── Main.scala │ │ └── Todo.scala │ │ └── wrk │ │ ├── Finagle.scala │ │ ├── Finch.scala │ │ └── Wrk.scala │ └── test │ └── scala │ └── io │ └── finch │ ├── div │ └── DivSpec.scala │ └── todo │ └── TodoSpec.scala ├── finch-logo.png ├── fs2 └── src │ ├── main │ └── scala │ │ └── io │ │ └── finch │ │ └── fs2 │ │ └── package.scala │ └── test │ └── scala │ └── io │ └── finch │ └── fs2 │ └── Fs2StreamingSpec.scala ├── generic └── src │ ├── main │ └── scala │ │ └── io │ │ └── finch │ │ └── generic │ │ ├── FromParams.scala │ │ ├── GenericDerivation.scala │ │ └── package.scala │ └── test │ └── scala │ └── io │ └── finch │ └── generic │ ├── DerivedEndpointLaws.scala │ └── GenericSpec.scala ├── iteratee └── src │ ├── main │ └── scala │ │ └── io │ │ └── finch │ │ └── iteratee │ │ └── package.scala │ └── test │ └── scala │ └── io │ └── finch │ └── iteratee │ └── IterateeStreamingSpec.scala ├── json-test └── src │ └── main │ └── scala │ └── io │ └── finch │ └── test │ ├── AbstractJsonSpec.scala │ ├── JsonLaws.scala │ └── data │ ├── ExampleCaseClass.scala │ └── ExampleNestedCaseClass.scala ├── project ├── build.properties └── plugins.sbt ├── refined └── src │ ├── main │ └── scala │ │ └── io │ │ └── finch │ │ └── refined │ │ ├── PredicateFailed.scala │ │ └── package.scala │ └── test │ └── scala │ └── io │ └── finch │ └── refined │ ├── DecodeEntityRefinedSpec.scala │ ├── DecodePathRefinedSpec.scala │ └── PredicateFailedSpec.scala ├── test └── src │ └── main │ └── scala │ └── io │ └── finch │ └── test │ ├── ServiceIntegrationSuite.scala │ └── ServiceSuite.scala └── version.sbt /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 5% 6 | 7 | patch: 8 | default: 9 | enabled: no 10 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.5.8 2 | 699c16e7a245b2ffee1ba4de9bfd1093a3d5072f 3 | 4 | # Scala Steward: Reformat with scalafmt 3.8.4 5 | cc2327d46a560d51bfc6d0e417dacd42fe8a8578 6 | 7 | # Scala Steward: Reformat with scalafmt 3.8.6 8 | 19f7c988f7aa16ab4154220681c654526116afa9 9 | 10 | # Scala Steward: Reformat with scalafmt 3.9.7 11 | 1b6f85fdfcddda282fcb25f4223df6b6168d1324 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Scala CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest] 18 | java: ['adopt@1.11'] 19 | 20 | runs-on: ${{ matrix.os }} 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: set up sbt and java 25 | uses: olafurpg/setup-scala@v13 26 | with: 27 | java-version: ${{ matrix.java }} 28 | - name: Run tests 29 | shell: bash 30 | env: 31 | ENCRYPTION_PASSWORD: ${{secrets.ENCRYPTION_PASSWORD}} 32 | JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF8 33 | run: ./build/build.sh 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | target/ 3 | .idea/ 4 | .idea_modules/ 5 | .DS_STORE 6 | .cache 7 | .settings 8 | .project 9 | .classpath 10 | local.* 11 | 12 | .bloop 13 | .metals 14 | .vscode 15 | project/metals.sbt 16 | project/project 17 | -------------------------------------------------------------------------------- /.scala-steward.conf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/.scala-steward.conf -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | OrganizeImports { 2 | groupedImports = Keep 3 | removeUnused = true 4 | } 5 | rules = [ 6 | OrganizeImports 7 | ] 8 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.9.7 2 | maxColumn = 160 3 | align.preset = some 4 | runner.dialect = scala213 5 | rewrite.rules = [ 6 | RedundantBraces, 7 | RedundantParens, 8 | SortModifiers, 9 | PreferCurlyFors 10 | ] 11 | assumeStandardLibraryStripMargin = true 12 | optIn.breakChainOnFirstMethodDot = false 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Generally, Finch follows a standard [fork and pull][0] model for contributions via GitHub pull requests. Thus, the 2 | _contributing process_ looks as follows: 3 | 4 | 0. [Pick an issue](#pick-an-issue) 5 | 1. [Write code](#write-code) 6 | 2. [Write tests](#write-tests) 7 | 3. [Write docs](#write-docs) 8 | 4. [Submit a PR](#submit-a-pr) 9 | 10 | ## Pick an issue 11 | 12 | * On [Waffle][5], pick any issue from column "Ready" 13 | * On Github, leave a comment on the issue you picked to notify others that the issues is taken 14 | * On [Gitter][6] or Github, ask any question you may have while working on the issue 15 | 16 | ## Write Code 17 | Finch follows the [Effective Scala][1] code style guide. When in doubt, look around the codebase and see how it's done 18 | elsewhere. 19 | 20 | * Code and comments should be formatted to a width no greater than 160 columns 21 | * Files should be exempt of trailing spaces 22 | * Each abstraction with corresponding implementations should live in its own Scala file, i.e `Endpoint.scala` 23 | * Each implicit conversion (if possible) should be defined in the corresponding companion object 24 | 25 | That said, the Scala source code shall be formatted with `sbt fmt` 26 | 27 | ## Write Tests 28 | Finch uses both [ScalaTest][2] and [ScalaCheck][3] with the following settings: 29 | 30 | * Every test should be a `FlatSpec` with `Matchers` and `Checkers` mixed in 31 | * An assertion in tests should be written with `x shouldBe y` 32 | * An assertion in properties (inside `check`) should be written with `===` 33 | * Exceptions should be intercepted with `an [Exception] shouldBe thrownBy(x)` 34 | 35 | ## Write Docs 36 | Write clean and simple docs in the `docs` folder. 37 | 38 | ## Submit a PR 39 | * PR should be submitted from a separate branch (use `git checkout -b "fix-123"`) 40 | * PR should generally contain only one commit (use `git commit --amend` and `git --force push` or [squash][4] existing commits into one) 41 | * PR should not decrease the code coverage more than by 1% 42 | * PR's commit message should use present tense and be capitalized properly (i.e., `Fix #123: Add tests for Endpoint`) 43 | * PR should pass `sbt validate` 44 | 45 | [0]: https://help.github.com/articles/using-pull-requests/ 46 | [1]: http://twitter.github.io/effectivescala/ 47 | [2]: http://www.scalatest.org/ 48 | [3]: https://www.scalacheck.org/ 49 | [4]: http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html 50 | [5]: https://waffle.io/finagle/finch 51 | [6]: https://gitter.im/finagle/finch 52 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Finch 2 | Copyright (c) 2014-2017, Vladimir Kostyukov, Travis Brown, Ryan Plessner and other 3 | contributors. All rights reserved. -------------------------------------------------------------------------------- /argonaut/src/main/scala/io/finch/argonaut/Decoders.scala: -------------------------------------------------------------------------------- 1 | package io.finch.argonaut 2 | 3 | import argonaut._ 4 | import cats.syntax.either._ 5 | import io.finch.Decode 6 | import io.finch.internal.HttpContent 7 | 8 | trait Decoders { 9 | 10 | /** Maps Argonaut's [[argonaut.DecodeJson]] to Finch's [[Decode]]. */ 11 | implicit def decodeArgonaut[A](implicit d: DecodeJson[A]): Decode.Json[A] = 12 | Decode.json { (b, cs) => 13 | Parse.parse(b.asString(cs)).flatMap(_.as[A].result.leftMap(_._1)) match { 14 | case Right(result) => Right(result) 15 | case Left(error) => Left(new Exception(error)) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /argonaut/src/main/scala/io/finch/argonaut/Encoders.scala: -------------------------------------------------------------------------------- 1 | package io.finch.argonaut 2 | 3 | import argonaut.{EncodeJson, PrettyParams} 4 | import com.twitter.io.Buf 5 | import io.finch.Encode 6 | 7 | trait Encoders { 8 | 9 | protected def printer: PrettyParams 10 | 11 | /** Maps Argonaut's [[argonaut.EncodeJson]] to Finch's [[Encode]]. */ 12 | implicit def encodeArgonaut[A](implicit e: EncodeJson[A]): Encode.Json[A] = 13 | Encode.json((a, cs) => Buf.ByteArray.Owned(printer.pretty(e.encode(a)).getBytes(cs.name))) 14 | } 15 | -------------------------------------------------------------------------------- /argonaut/src/main/scala/io/finch/argonaut/package.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import _root_.argonaut._ 4 | 5 | package object argonaut extends Encoders with Decoders { 6 | 7 | override protected val printer: PrettyParams = PrettyParams.nospace 8 | 9 | object dropNullKeys extends Encoders with Decoders { 10 | override protected val printer: PrettyParams = PrettyParams.nospace.copy(dropNullKeys = true) 11 | } 12 | 13 | /** Provides an implicit [[_root_.argonaut.PrettyParams]] that preserves order of the JSON fields. */ 14 | object preserveOrder extends Encoders with Decoders { 15 | override protected val printer: PrettyParams = PrettyParams.nospace.copy(preserveOrder = true) 16 | } 17 | 18 | /** Provides an implicit [[_root_.argonaut.PrettyParams]] that both preserves order of the JSON fields and drop null keys. */ 19 | object preserveOrderAndDropNullKeys extends Encoders with Decoders { 20 | override protected val printer: PrettyParams = PrettyParams.nospace.copy(preserveOrder = true, dropNullKeys = true) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /argonaut/src/test/scala/io/finch/argonaut/ArgonautSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch.argonaut 2 | 3 | import argonaut.Argonaut._ 4 | import argonaut._ 5 | import io.finch.test.AbstractJsonSpec 6 | import io.finch.test.data._ 7 | 8 | class ArgonautSpec extends AbstractJsonSpec { 9 | 10 | implicit val exampleCaseClassCodecJson: CodecJson[ExampleCaseClass] = 11 | casecodec3(ExampleCaseClass.apply, ExampleCaseClass.unapply)("a", "b", "c") 12 | 13 | implicit val exampleNestedCaseClassCodecJson: CodecJson[ExampleNestedCaseClass] = 14 | casecodec5(ExampleNestedCaseClass.apply, ExampleNestedCaseClass.unapply)( 15 | "string", 16 | "double", 17 | "long", 18 | "ints", 19 | "example" 20 | ) 21 | 22 | checkJson("argonaut") 23 | } 24 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/io/finch/data/Foo.scala: -------------------------------------------------------------------------------- 1 | package io.finch.data 2 | 3 | import com.twitter.io.Buf 4 | import io.finch.internal.HttpContent 5 | import io.finch.{Decode, DecodeEntity, Encode} 6 | 7 | case class Foo(s: String) 8 | 9 | object Foo { 10 | implicit val decodeEntityFoo: DecodeEntity[Foo] = 11 | DecodeEntity.instance(s => Right(Foo(s))) 12 | 13 | implicit val decodeFoo: Decode.Text[Foo] = 14 | Decode.text((b, cs) => Right(Foo(b.asString(cs)))) 15 | 16 | implicit val encodeFoo: Encode.Text[Foo] = 17 | Encode.text((f, cs) => Buf.ByteArray.Owned(f.s.getBytes(cs.name))) 18 | 19 | implicit def encodeList(implicit e: Encode.Text[Foo]): Encode.Text[List[Foo]] = 20 | Encode.text((fs, cs) => fs.map(f => e(f, cs)).reduce(_ concat _)) 21 | } 22 | -------------------------------------------------------------------------------- /build/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | SBT_CMD="sbt +validate" 5 | 6 | if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then 7 | SBT_CMD+=" +coverageOff +publish" 8 | 9 | for FILE in \ 10 | secring.gpg \ 11 | pubring.gpg \ 12 | credentials.sbt \ 13 | deploy_key.pem \ 14 | ; do 15 | openssl aes-256-cbc -salt -pbkdf2 -d \ 16 | -in "./build/${FILE}.enc" \ 17 | -out "local.${FILE}" \ 18 | -pass env:ENCRYPTION_PASSWORD 19 | done 20 | 21 | if [[ "${GITHUB_REF_NAME}" == "master" && $(cat version.sbt) != *"SNAPSHOT"* ]]; then 22 | eval "$(ssh-agent -s)" 23 | chmod 600 local.deploy_key.pem 24 | ssh-add local.deploy_key.pem 25 | chmod 600 local.secring.gpg 26 | chmod 600 local.pubring.gpg 27 | mkdir -p ~/.gnupg 28 | mv local.secring.gpg ~/.gnupg/secring.gpg 29 | mv local.pubring.gpg ~/.gnupg/pubring.gpg 30 | git config --global user.name "Finch CI" 31 | git config --global user.email "ci@kostyukov.net" 32 | git remote set-url origin git@github.com:finagle/finch.git 33 | git checkout master || git checkout -b master 34 | git reset --hard origin/master 35 | 36 | echo 'Performing a release' 37 | sbt 'release cross with-defaults' 38 | elif [[ "${GITHUB_REF_NAME}" == "master" ]]; then 39 | echo 'Master build' 40 | ${SBT_CMD} 41 | else 42 | echo 'Branch build' 43 | printf 'version in ThisBuild := "%s-SNAPSHOT"' "${GITHUB_REF_NAME}" > version.sbt 44 | ${SBT_CMD} 45 | fi 46 | else 47 | echo "${GITHUB_EVENT_NAME} build" 48 | ${SBT_CMD} 49 | fi 50 | -------------------------------------------------------------------------------- /build/credentials.sbt.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/build/credentials.sbt.enc -------------------------------------------------------------------------------- /build/deploy_key.pem.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/build/deploy_key.pem.enc -------------------------------------------------------------------------------- /build/pubring.gpg.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/build/pubring.gpg.enc -------------------------------------------------------------------------------- /build/secring.gpg.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/build/secring.gpg.enc -------------------------------------------------------------------------------- /circe/src/main/scala/io/finch/circe/AccumulatingDecoders.scala: -------------------------------------------------------------------------------- 1 | package io.finch.circe 2 | 3 | import cats.MonadThrow 4 | import cats.data.Validated 5 | import io.circe._ 6 | import io.circe.iteratee._ 7 | import io.circe.jawn.{decodeAccumulating, decodeByteBufferAccumulating} 8 | import io.finch.internal.HttpContent 9 | import io.finch.{Application, Decode, DecodeStream} 10 | import io.iteratee.{Enumeratee, Enumerator} 11 | 12 | import java.nio.charset.StandardCharsets 13 | 14 | trait AccumulatingDecoders { 15 | 16 | /** Maps a Circe's [[io.circe.Decoder]] to Finch's [[Decode]]. */ 17 | implicit def decodeCirce[A: Decoder]: Decode.Json[A] = 18 | Decode.json { (b, cs) => 19 | (cs match { 20 | case StandardCharsets.UTF_8 => decodeByteBufferAccumulating[A](b.asByteBuffer) 21 | case _ => decodeAccumulating[A](b.asString(cs)) 22 | }).fold(errors => Left(Errors(errors)), Right.apply) 23 | } 24 | 25 | implicit def iterateeCirceDecoder[F[_]: MonadThrow, A: Decoder]: DecodeStream.Json[Enumerator, F, A] = 26 | DecodeStream.instance[Enumerator, F, A, Application.Json] { (enum, cs) => 27 | (cs match { 28 | case StandardCharsets.UTF_8 => enum.map(_.asByteArray).through(byteStreamParser[F]) 29 | case _ => enum.map(_.asString(cs)).through(stringStreamParser[F]) 30 | }).through(decoderAccumulating[F, A]) 31 | } 32 | 33 | private def decoderAccumulating[F[_]: MonadThrow, A: Decoder]: Enumeratee[F, Json, A] = 34 | Enumeratee.flatMap { json => 35 | Decoder[A].decodeAccumulating(json.hcursor) match { 36 | case Validated.Invalid(errors) => Enumerator.liftM(MonadThrow[F].raiseError(Errors(errors))) 37 | case Validated.Valid(a) => Enumerator.enumOne(a) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /circe/src/main/scala/io/finch/circe/CirceError.scala: -------------------------------------------------------------------------------- 1 | package io.finch.circe 2 | 3 | import cats.syntax.show._ 4 | 5 | import scala.util.control.NoStackTrace 6 | 7 | private class CirceError(cause: io.circe.Error) extends Exception with NoStackTrace { 8 | override def getMessage: String = cause.show 9 | } 10 | -------------------------------------------------------------------------------- /circe/src/main/scala/io/finch/circe/Decoders.scala: -------------------------------------------------------------------------------- 1 | package io.finch.circe 2 | 3 | import cats.MonadThrow 4 | import cats.effect.Sync 5 | import cats.syntax.all._ 6 | import fs2.{Chunk, Stream} 7 | import io.circe.jawn._ 8 | import io.circe.{Decoder, fs2, iteratee} 9 | import io.finch.internal.HttpContent 10 | import io.finch.{Application, Decode, DecodeStream} 11 | import io.iteratee.Enumerator 12 | 13 | import java.nio.charset.StandardCharsets 14 | 15 | trait Decoders { 16 | 17 | /** Maps a Circe's [[io.circe.Decoder]] to Finch's [[Decode]]. */ 18 | implicit def decodeCirce[A: Decoder]: Decode.Json[A] = 19 | Decode.json { (b, cs) => 20 | (cs match { 21 | case StandardCharsets.UTF_8 => decodeByteBuffer[A](b.asByteBuffer) 22 | case _ => decode[A](b.asString(cs)) 23 | }).leftMap(new CirceError(_)) 24 | } 25 | 26 | implicit def enumerateCirce[F[_]: MonadThrow, A: Decoder]: DecodeStream.Json[Enumerator, F, A] = 27 | DecodeStream.instance[Enumerator, F, A, Application.Json] { (enum, cs) => 28 | (cs match { 29 | case StandardCharsets.UTF_8 => enum.map(_.asByteArray).through(iteratee.byteStreamParser[F]) 30 | case _ => enum.map(_.asString(cs)).through(iteratee.stringStreamParser[F]) 31 | }).through(iteratee.decoder[F, A]) 32 | } 33 | 34 | implicit def fs2Circe[F[_]: Sync, A: Decoder]: DecodeStream.Json[Stream, F, A] = 35 | DecodeStream.instance[Stream, F, A, Application.Json] { (stream, cs) => 36 | (cs match { 37 | case StandardCharsets.UTF_8 => 38 | stream.mapChunks(chunk => chunk.flatMap(buf => Chunk.array(buf.asByteArray))).through(fs2.byteStreamParser[F]) 39 | case _ => 40 | stream.map(_.asString(cs)).through(fs2.stringStreamParser[F]) 41 | }).through(fs2.decoder[F, A]) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /circe/src/main/scala/io/finch/circe/Encoders.scala: -------------------------------------------------------------------------------- 1 | package io.finch.circe 2 | 3 | import com.twitter.io.Buf 4 | import io.circe._ 5 | import io.finch.Encode 6 | 7 | import java.nio.charset.Charset 8 | 9 | trait Encoders { 10 | 11 | protected def print(json: Json, cs: Charset): Buf = 12 | Buf.ByteBuffer.Owned(Printer.noSpaces.printToByteBuffer(json, cs)) 13 | 14 | /** Maps Circe's [[io.circe.Encoder]] to Finch's [[Encode]]. */ 15 | implicit def encodeCirce[A](implicit e: Encoder[A]): Encode.Json[A] = 16 | Encode.json((a, cs) => print(e(a), cs)) 17 | } 18 | -------------------------------------------------------------------------------- /circe/src/main/scala/io/finch/circe/package.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import com.twitter.io.Buf 4 | import io.circe.{Json, Printer} 5 | 6 | import java.nio.charset.Charset 7 | 8 | package object circe extends Encoders with Decoders { 9 | 10 | /** Provides a [[io.circe.Printer]] that drops null keys. */ 11 | object dropNullValues extends Encoders with Decoders { 12 | private[this] val printer: Printer = Printer.noSpaces.copy(dropNullValues = true) 13 | override protected def print(json: Json, cs: Charset): Buf = 14 | Buf.ByteBuffer.Owned(printer.printToByteBuffer(json, cs)) 15 | } 16 | 17 | /** Provides a [[io.circe.Printer]] that uses a simple form of feedback-controller to predict the size of the printed message. */ 18 | object predictSize extends Encoders with Decoders { 19 | private[this] val printer: Printer = Printer.noSpaces.copy(predictSize = true) 20 | override protected def print(json: Json, cs: Charset): Buf = 21 | Buf.ByteBuffer.Owned(printer.printToByteBuffer(json, cs)) 22 | } 23 | 24 | object accumulating extends Encoders with AccumulatingDecoders 25 | } 26 | -------------------------------------------------------------------------------- /circe/src/test/scala/io/finch/circe/test/CirceSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch.circe.test 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import fs2.Stream 6 | import io.finch.test.AbstractJsonSpec 7 | import io.iteratee.Enumerator 8 | 9 | class CirceSpec extends AbstractJsonSpec { 10 | import io.finch.circe._ 11 | checkJson("circe") 12 | checkStreamJson[Enumerator, IO]("circe-iteratee")(Enumerator.enumList, _.toVector.unsafeRunSync().toList) 13 | checkStreamJson[Stream, IO]("circe-fs2")( 14 | list => Stream.fromIterator[IO](list.iterator, 1024), 15 | _.compile.toList.unsafeRunSync() 16 | ) 17 | } 18 | 19 | class CirceAccumulatingSpec extends AbstractJsonSpec { 20 | import io.finch.circe.accumulating._ 21 | checkJson("circe-accumulating") 22 | checkStreamJson[Enumerator, IO]("circe-accumulating")(Enumerator.enumList, _.toVector.unsafeRunSync().toList) 23 | } 24 | 25 | class CirceDropNullKeysSpec extends AbstractJsonSpec { 26 | import io.finch.circe.dropNullValues._ 27 | checkJson("circe-dropNullKeys") 28 | } 29 | 30 | class CircePredictSizeSpec extends AbstractJsonSpec { 31 | import io.finch.circe.predictSize._ 32 | checkJson("circe-predictSize") 33 | } 34 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Accept.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import shapeless.Witness 4 | 5 | import java.util.Locale 6 | 7 | /** Models an HTTP Accept header (see RFC2616, 14.1). 8 | * 9 | * @note 10 | * This API doesn't validate the input primary/sub types. 11 | * 12 | * @see 13 | * https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html 14 | */ 15 | abstract class Accept { 16 | def primary: String 17 | def sub: String 18 | def matches[CT <: String](implicit m: Accept.Matcher[CT]): Boolean = m(this) 19 | 20 | override def toString: String = s"Accept: $primary/$sub" 21 | } 22 | 23 | object Accept { 24 | 25 | private object Empty extends Accept { 26 | def primary: String = "" 27 | def sub: String = "" 28 | override def matches[CT <: String](implicit m: Matcher[CT]): Boolean = false 29 | } 30 | 31 | abstract class Matcher[CT <: String] { 32 | def apply(a: Accept): Boolean 33 | def apply(as: List[Accept]): Boolean = as.exists(apply) 34 | } 35 | 36 | object Matcher { 37 | 38 | private object Empty extends Matcher[Nothing] { 39 | def apply(a: Accept): Boolean = false 40 | } 41 | 42 | implicit val json: Matcher[Application.Json] = fromWitness[Application.Json] 43 | implicit val xml: Matcher[Application.Xml] = fromWitness[Application.Xml] 44 | implicit val text: Matcher[Text.Plain] = fromWitness[Text.Plain] 45 | implicit val html: Matcher[Text.Html] = fromWitness[Text.Html] 46 | 47 | implicit def fromWitness[CT <: String](implicit w: Witness.Aux[CT]): Matcher[CT] = { 48 | val slashIndex = w.value.indexOf(47) 49 | if (slashIndex == 0 || slashIndex == w.value.length) Empty.asInstanceOf[Matcher[CT]] 50 | else 51 | new Matcher[CT] { 52 | private val primary: String = w.value.substring(0, slashIndex).trim.toLowerCase(Locale.ENGLISH) 53 | private val sub: String = w.value.substring(slashIndex + 1, w.value.length).trim.toLowerCase(Locale.ENGLISH) 54 | def apply(a: Accept): Boolean = 55 | (a.primary == "*" && a.sub == "*") || (a.primary == primary && (a.sub == sub || a.sub == "*")) 56 | } 57 | } 58 | } 59 | 60 | /** Parses an [[Accept]] instance from a given string. Returns `null` when not able to parse. 61 | */ 62 | def fromString(s: String): Accept = { 63 | // Adopted from Java's MimeType's API. 64 | val slashIndex = s.indexOf(47) 65 | val semIndex = s.indexOf(59) 66 | val length = if (semIndex < 0) s.length else semIndex 67 | 68 | if (slashIndex < 0 || slashIndex >= length) Empty 69 | else 70 | new Accept { 71 | val primary: String = s.substring(0, slashIndex).trim.toLowerCase(Locale.ENGLISH) 72 | val sub: String = s.substring(slashIndex + 1, length).trim.toLowerCase(Locale.ENGLISH) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Bootstrap.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.std.Dispatcher 4 | import cats.effect.{Async, Resource} 5 | import com.twitter.finagle.http.{Request, Response} 6 | import com.twitter.finagle.{Filter, Http, ListeningServer, Service} 7 | import io.finch.internal.TwitterFutureConverter 8 | import shapeless._ 9 | 10 | /** Bootstraps a Finagle HTTP listening server out of the collection of Finch endpoints. 11 | * 12 | * {{{ 13 | * val api: Service[Request, Response] = Bootstrap[F] 14 | * .configure(includeServerHeader = false, enableMethodNotAllowed = true) 15 | * .serve[Application.Json](getUser :+: postUser) 16 | * .serve[Text.Plain](healthcheck) 17 | * .listen(":80") 18 | * }}} 19 | * 20 | * ==Supported Configuration Options== 21 | * 22 | * - `includeDateHeader` (default: `true`): whether or not to include the Date header into each response (see RFC2616, section 14.18) 23 | * - `includeServerHeader` (default: `true`): whether or not to include the Server header into each response (see RFC2616, section 14.38) 24 | * - `enableMethodNotAllowed` (default: `false`): whether or not to enable 405 MethodNotAllowed HTTP response (see RFC2616, section 10.4.6) 25 | * - `enableUnsupportedMediaType` (default: `false`) whether or not to enable 415 UnsupportedMediaType HTTP response (see RFC7231, section 6.5.13) 26 | * 27 | * @see 28 | * https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html 29 | * @see 30 | * https://www.w3.org/Protocols/rfc2616/rfc2616-sec12.html 31 | * @see 32 | * https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html 33 | * @see 34 | * https://tools.ietf.org/html/rfc7231#section-6.5.13 35 | */ 36 | class Bootstrap[F[_], ES <: HList, CTS <: HList]( 37 | endpoints: ES, 38 | server: => Http.Server = Http.server, 39 | filter: Filter[Request, Response, Request, Response] = Filter.identity[Request, Response], 40 | middleware: Endpoint.Compiled[F] => Endpoint.Compiled[F] = identity[Endpoint.Compiled[F]] _, 41 | includeDateHeader: Boolean = true, 42 | includeServerHeader: Boolean = true, 43 | enableMethodNotAllowed: Boolean = false, 44 | enableUnsupportedMediaType: Boolean = false, 45 | enableNotAcceptable: Boolean = false 46 | ) { 47 | 48 | class Serve[CT] { 49 | def apply[E](e: Endpoint[F, E]): Bootstrap[F, Endpoint[F, E] :: ES, CT :: CTS] = 50 | new Bootstrap( 51 | e :: endpoints, 52 | server, 53 | filter, 54 | middleware, 55 | includeDateHeader, 56 | includeServerHeader, 57 | enableMethodNotAllowed, 58 | enableUnsupportedMediaType, 59 | enableNotAcceptable 60 | ) 61 | } 62 | 63 | def configure( 64 | includeDateHeader: Boolean = includeDateHeader, 65 | includeServerHeader: Boolean = includeServerHeader, 66 | enableMethodNotAllowed: Boolean = enableMethodNotAllowed, 67 | enableUnsupportedMediaType: Boolean = enableUnsupportedMediaType, 68 | enableNotAcceptable: Boolean = enableNotAcceptable 69 | ): Bootstrap[F, ES, CTS] = new Bootstrap( 70 | endpoints, 71 | server, 72 | filter, 73 | middleware, 74 | includeDateHeader, 75 | includeServerHeader, 76 | enableMethodNotAllowed, 77 | enableUnsupportedMediaType, 78 | enableNotAcceptable 79 | ) 80 | 81 | def serve[CT]: Serve[CT] = new Serve[CT] 82 | 83 | def filter(f: Filter[Request, Response, Request, Response]): Bootstrap[F, ES, CTS] = 84 | new Bootstrap( 85 | endpoints, 86 | server, 87 | f.andThen(filter), 88 | middleware, 89 | includeDateHeader, 90 | includeServerHeader, 91 | enableMethodNotAllowed, 92 | enableUnsupportedMediaType, 93 | enableNotAcceptable 94 | ) 95 | 96 | def middleware(f: Endpoint.Compiled[F] => Endpoint.Compiled[F]): Bootstrap[F, ES, CTS] = 97 | new Bootstrap( 98 | endpoints, 99 | server, 100 | filter, 101 | f.andThen(middleware), 102 | includeDateHeader, 103 | includeServerHeader, 104 | enableMethodNotAllowed, 105 | enableUnsupportedMediaType, 106 | enableNotAcceptable 107 | ) 108 | 109 | private[finch] def compile(implicit ts: Compile[F, ES, CTS]): Endpoint.Compiled[F] = { 110 | val options = Compile.Options( 111 | includeDateHeader, 112 | includeServerHeader, 113 | enableMethodNotAllowed, 114 | enableUnsupportedMediaType, 115 | enableNotAcceptable 116 | ) 117 | 118 | middleware(ts(endpoints, options, Compile.Context())) 119 | } 120 | 121 | def toService(implicit F: Async[F], ts: Compile[F, ES, CTS]): Resource[F, Service[Request, Response]] = 122 | Dispatcher.parallel[F].flatMap(toService) 123 | 124 | def toService(dispatcher: Dispatcher[F])(implicit F: Async[F], ts: Compile[F, ES, CTS]): Resource[F, Service[Request, Response]] = 125 | Resource.make(F.pure(ToService(compile, dispatcher))) { service => 126 | F.defer(service.close().toAsync) 127 | } 128 | 129 | def listen(address: String)(implicit F: Async[F], ts: Compile[F, ES, CTS]): Resource[F, ListeningServer] = 130 | toService.flatMap { service => 131 | val filtered = filter.andThen(service) 132 | Resource.make(F.delay(server.serve(address, filtered))) { listening => 133 | F.defer(listening.close().toAsync) 134 | } 135 | } 136 | 137 | final override def toString: String = 138 | s"Bootstrap($endpoints)" 139 | } 140 | 141 | object Bootstrap { 142 | def apply[F[_]]: Bootstrap[F, HNil, HNil] = Bootstrap(Http.server) 143 | def apply[F[_]](server: Http.Server): Bootstrap[F, HNil, HNil] = new Bootstrap(HNil, server) 144 | } 145 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Compile.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.syntax.all._ 4 | import cats.{Applicative, MonadThrow} 5 | import com.twitter.finagle.http.{Method, Response, Status, Version} 6 | import io.finch.internal.currentTime 7 | import shapeless._ 8 | 9 | import scala.annotation.implicitNotFound 10 | 11 | /** Compiles a given list of [[Endpoint]]s and their content-types into single [[Endpoint.Compiled]]. 12 | * 13 | * Guarantees to: 14 | * 15 | * - handle Finch's own errors (i.e., [[Error]] and [[Error]]) as 400s 16 | * - copy requests's HTTP version onto a response 17 | * - respond with 404 when an endpoint is not matched 18 | * - respond with 405 when an endpoint is not matched because method wasn't allowed (serve back an `Allow` header) 19 | * - include the date header on each response (unless disabled) 20 | * - include the server header on each response (unless disabled) 21 | */ 22 | @implicitNotFound("""An Endpoint you're trying to compile is missing one or more encoders. 23 | 24 | Make sure each endpoint in ${ES}, ${CTS} is one of the following: 25 | 26 | * A com.twitter.finagle.http.Response 27 | * A value of a type with an io.finch.Encode instance (with the corresponding content-type) 28 | * A coproduct made up of some combination of the above 29 | 30 | See https://github.com/finagle/finch/blob/master/docs/src/main/tut/cookbook.md#fixing-the-toservice-compile-error 31 | """) 32 | trait Compile[F[_], ES <: HList, CTS <: HList] { 33 | def apply(endpoints: ES, options: Compile.Options, context: Compile.Context): Endpoint.Compiled[F] 34 | } 35 | 36 | object Compile { 37 | 38 | /** HTTP options propagated from [[Bootstrap]]. */ 39 | final case class Options( 40 | includeDateHeader: Boolean, 41 | includeServerHeader: Boolean, 42 | enableMethodNotAllowed: Boolean, 43 | enableUnsupportedMediaType: Boolean, 44 | enableNotAcceptable: Boolean 45 | ) 46 | 47 | /** HTTP context propagated between endpoints. 48 | * 49 | * - `wouldAllow`: when non-empty, indicates that the incoming method wasn't allowed/matched 50 | */ 51 | final case class Context(wouldAllow: List[Method] = Nil) 52 | 53 | private[this] val respond400: PartialFunction[Throwable, Output[Nothing]] = { 54 | case e: io.finch.Error => Output.failure(e, Status.BadRequest) 55 | case es: io.finch.Errors => Output.failure(es, Status.BadRequest) 56 | } 57 | 58 | private[this] val respond415: PartialFunction[Throwable, Output[Nothing]] = { 59 | case e: io.finch.Error if e.getCause eq Decode.UnsupportedMediaTypeException => 60 | Output.failure(e, Status.UnsupportedMediaType) 61 | } 62 | 63 | private def conformHttp(rep: Response, version: Version, opts: Options): Response = { 64 | rep.version = version 65 | if (opts.includeDateHeader) rep.headerMap.setUnsafe("Date", currentTime()) 66 | if (opts.includeServerHeader) rep.headerMap.setUnsafe("Server", "Finch") 67 | rep 68 | } 69 | 70 | implicit def hnilTS[F[_]](implicit F: Applicative[F]): Compile[F, HNil, HNil] = (_, opts, ctx) => 71 | Endpoint.Compiled { req => 72 | val notAllowed = opts.enableMethodNotAllowed && ctx.wouldAllow.nonEmpty 73 | val rep = Response(if (notAllowed) Status.MethodNotAllowed else Status.NotFound) 74 | if (notAllowed) rep.allow = ctx.wouldAllow 75 | F.pure((Trace.empty, Right(conformHttp(rep, req.version, opts)))) 76 | } 77 | 78 | implicit def hlistTS[F[_]: MonadThrow, A, ET <: HList, CTH, CTT <: HList](implicit 79 | negotiable: ToResponse.Negotiable[F, A, CTH], 80 | rest: Compile[F, ET, CTT], 81 | isNegotiable: CTH <:< Coproduct = null 82 | ): Compile[F, Endpoint[F, A] :: ET, CTH :: CTT] = { case (e :: es, opts, ctx) => 83 | val endpoint = e.handle(if (opts.enableUnsupportedMediaType) respond415 orElse respond400 else respond400) 84 | Endpoint.Compiled { req => 85 | endpoint(Input.fromRequest(req)) match { 86 | case EndpointResult.Matched(rem, trc, out) if rem.route.isEmpty => 87 | val negotiate = isNegotiable != null || (opts.enableNotAcceptable && req.accept.nonEmpty) 88 | val negotiated = negotiable(if (negotiate) req.accept.map(Accept.fromString).toList else Nil) 89 | val acceptable = !negotiate || negotiated.acceptable || !opts.enableNotAcceptable 90 | val rep = if (acceptable) out.flatMap(_.toResponse(negotiated)) else Response(Status.NotAcceptable).pure[F] 91 | rep.map(conformHttp(_, req.version, opts)).attempt.map((trc, _)) 92 | case EndpointResult.NotMatched.MethodNotAllowed(allowed) => 93 | rest(es, opts, ctx.copy(wouldAllow = ctx.wouldAllow ++ allowed))(req) 94 | case _ => 95 | rest(es, opts, ctx)(req) 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Decode.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import com.twitter.io.Buf 4 | import shapeless.{:+:, CNil, Coproduct, Witness} 5 | 6 | import java.nio.charset.Charset 7 | import scala.util.control.NoStackTrace 8 | 9 | /** Decodes an HTTP payload represented as [[com.twitter.io.Buf]] (encoded with `Charset`) into an arbitrary type `A`. */ 10 | trait Decode[A] { 11 | type ContentType <: String 12 | 13 | def apply(b: Buf, cs: Charset): Either[Throwable, A] 14 | } 15 | 16 | object Decode { 17 | 18 | /** Indicates that a payload can not be decoded with a given [[Decode]] instance (or a coproduct of instances). 19 | */ 20 | object UnsupportedMediaTypeException extends Exception with NoStackTrace 21 | 22 | type Aux[A, CT <: String] = Decode[A] { type ContentType = CT } 23 | 24 | type Json[A] = Aux[A, Application.Json] 25 | type Text[A] = Aux[A, Text.Plain] 26 | 27 | /** Creates an instance for a given type. 28 | */ 29 | def instance[A, CT <: String](fn: (Buf, Charset) => Either[Throwable, A]): Aux[A, CT] = new Decode[A] { 30 | type ContentType = CT 31 | def apply(b: Buf, cs: Charset): Either[Throwable, A] = fn(b, cs) 32 | } 33 | 34 | def json[A](fn: (Buf, Charset) => Either[Throwable, A]): Json[A] = 35 | instance[A, Application.Json](fn) 36 | 37 | def text[A](fn: (Buf, Charset) => Either[Throwable, A]): Text[A] = 38 | instance[A, Text.Plain](fn) 39 | 40 | /** Returns a [[Decode]] instance for a given type (with required content type). 41 | */ 42 | @inline def apply[A, CT <: String](implicit d: Aux[A, CT]): Aux[A, CT] = d 43 | 44 | /** Abstracting over [[Decode]] to select a correct decoder according to the `Content-Type` header value. 45 | */ 46 | trait Dispatchable[A, CT] { 47 | def apply(ct: String, b: Buf, cs: Charset): Either[Throwable, A] 48 | } 49 | 50 | object Dispatchable { 51 | 52 | implicit def cnilToDispatchable[A]: Dispatchable[A, CNil] = new Dispatchable[A, CNil] { 53 | def apply(ct: String, b: Buf, cs: Charset): Either[Throwable, A] = 54 | Left(Decode.UnsupportedMediaTypeException) 55 | } 56 | 57 | implicit def coproductToDispatchable[A, CTH <: String, CTT <: Coproduct](implicit 58 | decode: Decode.Aux[A, CTH], 59 | witness: Witness.Aux[CTH], 60 | tail: Dispatchable[A, CTT] 61 | ): Dispatchable[A, CTH :+: CTT] = new Dispatchable[A, CTH :+: CTT] { 62 | def apply(ct: String, b: Buf, cs: Charset): Either[Throwable, A] = 63 | if (ct.equalsIgnoreCase(witness.value)) decode(b, cs) 64 | else tail(ct, b, cs) 65 | } 66 | 67 | implicit def singleToDispatchable[A, CT <: String](implicit 68 | decode: Decode.Aux[A, CT], 69 | witness: Witness.Aux[CT] 70 | ): Dispatchable[A, CT] = coproductToDispatchable[A, CT, CNil].asInstanceOf[Dispatchable[A, CT]] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/DecodeEntity.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import shapeless._ 4 | 5 | import java.util.UUID 6 | 7 | /** Decodes an HTTP entity (eg: header, query-string param) represented as UTF-8 `String` into an arbitrary type `A`. 8 | */ 9 | trait DecodeEntity[A] { 10 | def apply(s: String): Either[Throwable, A] 11 | } 12 | 13 | object DecodeEntity extends HighPriorityDecode { 14 | 15 | /** Returns a [[DecodeEntity]] instance for a given type. 16 | */ 17 | @inline def apply[A](implicit d: DecodeEntity[A]): DecodeEntity[A] = d 18 | 19 | implicit val decodeString: DecodeEntity[String] = instance(s => Right(s)) 20 | 21 | } 22 | 23 | trait HighPriorityDecode extends LowPriorityDecode { 24 | 25 | implicit val decodeInt: DecodeEntity[Int] = instance(s => 26 | try Right(s.toInt) 27 | catch { case e: Throwable => Left(e) } 28 | ) 29 | 30 | implicit val decodeLong: DecodeEntity[Long] = instance(s => 31 | try Right(s.toLong) 32 | catch { case e: Throwable => Left(e) } 33 | ) 34 | 35 | implicit val decodeFloat: DecodeEntity[Float] = instance(s => 36 | try Right(s.toFloat) 37 | catch { case e: Throwable => Left(e) } 38 | ) 39 | 40 | implicit val decodeDouble: DecodeEntity[Double] = instance(s => 41 | try Right(s.toDouble) 42 | catch { case e: Throwable => Left(e) } 43 | ) 44 | 45 | implicit val decodeBoolean: DecodeEntity[Boolean] = instance(s => 46 | try Right(s.toBoolean) 47 | catch { case e: Throwable => Left(e) } 48 | ) 49 | 50 | implicit val decodeUUID: DecodeEntity[UUID] = instance(s => 51 | if (s.length != 36) Left(new IllegalArgumentException(s"Too long for UUID: ${s.length}")) 52 | else 53 | try Right(UUID.fromString(s)) 54 | catch { case e: Throwable => Left(e) } 55 | ) 56 | 57 | } 58 | 59 | trait LowPriorityDecode { 60 | 61 | /** Creates an [[DecodeEntity]] instance from a given function `String => Either[Throwable, A]`. */ 62 | def instance[A](fn: String => Either[Throwable, A]): DecodeEntity[A] = fn(_) 63 | 64 | /** Creates a [[Decode]] from `Generic` for single value case classes. */ 65 | implicit def decodeFromGeneric[A, H <: HList, E](implicit 66 | gen: Generic.Aux[A, H], 67 | ev: (E :: HNil) =:= H, 68 | de: DecodeEntity[E] 69 | ): DecodeEntity[A] = instance(de(_).map(b => gen.from(b :: HNil))) 70 | } 71 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/DecodePath.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import io.finch.internal.TooFastString 4 | 5 | import java.util.UUID 6 | 7 | /** Decodes an HTTP path (eg: /foo/bar/baz) represented as UTF-8 `String` into an arbitrary type `A`. 8 | */ 9 | trait DecodePath[A] { 10 | def apply(s: String): Option[A] 11 | } 12 | 13 | object DecodePath { 14 | 15 | @inline def apply[A](implicit d: DecodePath[A]): DecodePath[A] = d 16 | 17 | def instance[A](fn: String => Option[A]): DecodePath[A] = new DecodePath[A] { 18 | def apply(s: String): Option[A] = fn(s) 19 | } 20 | 21 | implicit val decodePath: DecodePath[String] = instance(Some.apply) 22 | implicit val decodeInt: DecodePath[Int] = instance(_.tooInt) 23 | implicit val decodeLong: DecodePath[Long] = instance(_.tooLong) 24 | implicit val decodeBoolean: DecodePath[Boolean] = instance(_.tooBoolean) 25 | implicit val decodeUUID: DecodePath[UUID] = instance { s => 26 | if (s.length != 36) None 27 | else 28 | try Some(UUID.fromString(s)) 29 | catch { case _: Exception => None } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/DecodeStream.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import com.twitter.io.Buf 4 | 5 | import java.nio.charset.Charset 6 | import scala.annotation.implicitNotFound 7 | 8 | /** Stream HTTP streamed payload represented as S[F, Buf] into a S[F, A] of arbitrary type `A`. 9 | */ 10 | trait DecodeStream[S[_[_], _], F[_], A] { 11 | 12 | type ContentType <: String 13 | 14 | def apply(stream: S[F, Buf], cs: Charset): S[F, A] 15 | 16 | } 17 | 18 | object DecodeStream { 19 | 20 | @implicitNotFound( 21 | """A stream* endpoint requires implicit DecodeStream instance in scope, probably streaming decoder for ${A} is missing. 22 | 23 | Make sure ${A} is one of the following: 24 | 25 | * A com.twitter.io.Buf 26 | * A value of a type with an io.finch.DecodeStream instance (with the corresponding content-type) 27 | 28 | Help: If you're looking for JSON stream decoding, consider to use decoder from finch-circe library 29 | """ 30 | ) 31 | type Aux[S[_[_], _], F[_], A, CT <: String] = DecodeStream[S, F, A] { type ContentType = CT } 32 | 33 | type Json[S[_[_], _], F[_], A] = Aux[S, F, A, Application.Json] 34 | 35 | def instance[S[_[_], _], F[_], A, CT <: String](f: (S[F, Buf], Charset) => S[F, A]): DecodeStream.Aux[S, F, A, CT] = 36 | new DecodeStream[S, F, A] { 37 | type ContentType = CT 38 | 39 | def apply(stream: S[F, Buf], cs: Charset): S[F, A] = f(stream, cs) 40 | 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Encode.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Show 4 | import com.twitter.io.Buf 5 | 6 | import java.nio.charset.Charset 7 | 8 | /** Encodes an HTTP payload (represented as an arbitrary type `A`) with a given `Charset`. */ 9 | trait Encode[A] { 10 | type ContentType <: String 11 | 12 | def apply(a: A, cs: Charset): Buf 13 | } 14 | 15 | trait LowPriorityEncodeInstances { 16 | 17 | type Aux[A, CT <: String] = Encode[A] { type ContentType = CT } 18 | 19 | type Json[A] = Aux[A, Application.Json] 20 | type Text[A] = Aux[A, Text.Plain] 21 | 22 | final def instance[A, CT <: String](fn: (A, Charset) => Buf): Aux[A, CT] = 23 | new Encode[A] { 24 | type ContentType = CT 25 | def apply(a: A, cs: Charset): Buf = fn(a, cs) 26 | } 27 | 28 | final def json[A](fn: (A, Charset) => Buf): Json[A] = 29 | instance[A, Application.Json](fn) 30 | 31 | final def text[A](fn: (A, Charset) => Buf): Text[A] = 32 | instance[A, Text.Plain](fn) 33 | 34 | implicit def encodeShowAsTextPlain[A](implicit s: Show[A]): Text[A] = 35 | text((a, cs) => Buf.ByteArray.Owned(s.show(a).getBytes(cs.name))) 36 | } 37 | 38 | trait HighPriorityEncodeInstances extends LowPriorityEncodeInstances { 39 | 40 | final private[this] val anyToEmptyBuf: Aux[Any, Nothing] = 41 | instance[Any, Nothing]((_, _) => Buf.Empty) 42 | 43 | final private[this] val bufToBuf: Aux[Buf, Nothing] = 44 | instance[Buf, Nothing]((buf, _) => buf) 45 | 46 | implicit def encodeUnit[CT <: String]: Aux[Unit, CT] = 47 | anyToEmptyBuf.asInstanceOf[Aux[Unit, CT]] 48 | 49 | implicit def encodeException[CT <: String]: Aux[Exception, CT] = 50 | anyToEmptyBuf.asInstanceOf[Aux[Exception, CT]] 51 | 52 | implicit def encodeBuf[CT <: String]: Aux[Buf, CT] = 53 | bufToBuf.asInstanceOf[Aux[Buf, CT]] 54 | } 55 | 56 | object Encode extends HighPriorityEncodeInstances { 57 | 58 | /** Returns a [[Encode]] instance for a given type (with required content type). 59 | */ 60 | @inline final def apply[A, CT <: String](implicit e: Aux[A, CT]): Aux[A, CT] = e 61 | 62 | implicit val encodeExceptionAsTextPlain: Text[Exception] = 63 | text((e, cs) => Buf.ByteArray.Owned(Option(e.getMessage).getOrElse("").getBytes(cs.name))) 64 | 65 | implicit val encodeStringAsTextPlain: Text[String] = 66 | text((s, cs) => Buf.ByteArray.Owned(s.getBytes(cs.name))) 67 | } 68 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/EncodeStream.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import com.twitter.io.{Buf, Reader} 4 | 5 | import java.nio.charset.Charset 6 | 7 | /** A type-class that defines encoding of a stream in a shape of `S[F[_], A]` to Finagle's [[com.twitter.io.Reader]]. */ 8 | trait EncodeStream[F[_], S[_[_], _], A] { 9 | 10 | type ContentType <: String 11 | 12 | def apply(s: S[F, A], cs: Charset): F[Reader[Buf]] 13 | } 14 | 15 | object EncodeStream { 16 | 17 | type Aux[F[_], S[_[_], _], A, CT <: String] = 18 | EncodeStream[F, S, A] { type ContentType = CT } 19 | 20 | type Json[F[_], S[_[_], _], A] = Aux[F, S, A, Application.Json] 21 | 22 | type Text[F[_], S[_[_], _], A] = Aux[F, S, A, Text.Plain] 23 | } 24 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/EndpointResult.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.{ApplicativeThrow, Id} 4 | import com.twitter.finagle.http.Method 5 | 6 | /** A result returned from an [[Endpoint]]. This models `Option[(Input, Future[Output])]` and represents two cases: 7 | * 8 | * - Endpoint is matched (think of 200). 9 | * - Endpoint is not matched (think of 404, 405, etc). 10 | * 11 | * In its current state, `EndpointResult.NotMatched` represented with two cases: 12 | * 13 | * - `EndpointResult.NotMatched` (very generic result usually indicating 404) 14 | * - `EndpointResult.NotMatched.MethodNotAllowed` (indicates 405) 15 | */ 16 | sealed abstract class EndpointResult[F[_], +A] { 17 | 18 | /** Whether the [[Endpoint]] is matched on a given [[Input]]. */ 19 | final def isMatched: Boolean = this match { 20 | case EndpointResult.Matched(_, _, _) => true 21 | case _ => false 22 | } 23 | 24 | /** Returns the remainder of the [[Input]] after an [[Endpoint]] is matched. */ 25 | final def remainder: Option[Input] = this match { 26 | case EndpointResult.Matched(rem, _, _) => Some(rem) 27 | case _ => None 28 | } 29 | 30 | /** Returns the [[Trace]] if an [[Endpoint]] is matched. */ 31 | final def trace: Option[Trace] = this match { 32 | case EndpointResult.Matched(_, trc, _) => Some(trc) 33 | case _ => None 34 | } 35 | } 36 | 37 | object EndpointResult { 38 | 39 | final case class Matched[F[_], A]( 40 | rem: Input, 41 | trc: Trace, 42 | out: F[Output[A]] 43 | ) extends EndpointResult[F, A] 44 | 45 | abstract class NotMatched[F[_]] extends EndpointResult[F, Nothing] 46 | 47 | object NotMatched extends NotMatched[Id] { 48 | final case class MethodNotAllowed[F[_]](allowed: List[Method]) extends NotMatched[F] 49 | 50 | def apply[F[_]]: NotMatched[F] = NotMatched.asInstanceOf[NotMatched[F]] 51 | } 52 | 53 | implicit class EndpointResultOps[F[_], A](val self: EndpointResult[F, A]) extends AnyVal { 54 | 55 | /** Returns the [[Output]] if an [[Endpoint]] is matched. */ 56 | def outputAttempt(implicit F: ApplicativeThrow[F]): F[Either[Throwable, Output[A]]] = 57 | F.attempt(output) 58 | 59 | def outputOption(implicit F: ApplicativeThrow[F]): F[Option[Output[A]]] = 60 | F.map(outputAttempt)(_.toOption) 61 | 62 | def output(implicit F: ApplicativeThrow[F]): F[Output[A]] = self match { 63 | case EndpointResult.Matched(_, _, out) => out 64 | case _ => F.raiseError(NotMatchedError) 65 | } 66 | 67 | def valueAttempt(implicit F: ApplicativeThrow[F]): F[Either[Throwable, A]] = 68 | F.attempt(value) 69 | 70 | def valueOption(implicit F: ApplicativeThrow[F]): F[Option[A]] = 71 | F.map(valueAttempt)(_.toOption) 72 | 73 | def value(implicit F: ApplicativeThrow[F]): F[A] = 74 | F.map(output)(_.value) 75 | } 76 | 77 | private case object NotMatchedError extends NoSuchElementException("Endpoint didn't match") 78 | } 79 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Error.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.data.{NonEmptyChain, NonEmptyList} 4 | import cats.{Eq, Semigroup, Show} 5 | 6 | import scala.reflect.ClassTag 7 | import scala.util.control.NoStackTrace 8 | 9 | /** A single error from an [[Endpoint]]. 10 | * 11 | * This indicates that one of the Finch's built-in components failed. This includes, but not limited by: 12 | * 13 | * - reading a required param, body, header, etc. 14 | * - parsing a string-based endpoint with `.as[T]` combinator 15 | */ 16 | sealed abstract class Error extends Exception with NoStackTrace 17 | 18 | /** Multiple errors from an [[Endpoint]]. 19 | * 20 | * This type of error indicates that an endpoint is able to accumulate multiple [[Error]]s into a single instance of [[Errors]] that embeds a non-empty list. 21 | * 22 | * Error accumulation happens as part of the `.product` (or `adjoin`, `::`) combinator. 23 | */ 24 | final case class Errors(errors: NonEmptyChain[Error]) extends Exception with NoStackTrace { 25 | override def getMessage: String = 26 | "One or more errors reading request:" + 27 | errors.iterator.map(_.getMessage).mkString(System.lineSeparator + " ", System.lineSeparator + " ", "") 28 | } 29 | 30 | object Errors { 31 | def apply(errors: NonEmptyList[Error]): Errors = 32 | Errors(NonEmptyChain.fromNonEmptyList(errors)) 33 | 34 | def of(error: Error, errors: Error*): Errors = 35 | Errors(NonEmptyChain.of(error, errors: _*)) 36 | 37 | implicit val eq: Eq[Errors] = Eq.by(_.errors) 38 | implicit val semigroup: Semigroup[Errors] = 39 | (xs, ys) => Errors(xs.errors ++ ys.errors) 40 | } 41 | 42 | object Error { 43 | implicit val eq: Eq[Error] = Eq.by(_.getMessage) 44 | implicit val show: Show[Error] = _.getMessage 45 | 46 | /** A request entity {{what}} was missing. */ 47 | abstract class NotPresent(what: String) extends Error { 48 | override def getMessage: String = s"Request is missing a $what." 49 | } 50 | 51 | final case object BodyNotPresent extends NotPresent("body") 52 | final case class ParamNotPresent(name: String) extends NotPresent(s"param '$name'") 53 | final case class HeaderNotPresent(name: String) extends NotPresent(s"header '$name'") 54 | final case class CookieNotPresent(name: String) extends NotPresent(s"cookie '$name''") 55 | 56 | /** A request entity {{what}} can't be parsed into a given {{targetType}}. */ 57 | abstract class NotParsed(what: String, targetType: ClassTag[_]) extends Error { 58 | override def getMessage: String = { 59 | // Note: https://issues.scala-lang.org/browse/SI-2034 60 | val className = targetType.runtimeClass.getName 61 | val simpleName = className.substring(className.lastIndexOf(".") + 1) 62 | val cause = if (getCause == null) "unknown cause" else getCause.getMessage 63 | s"Request $what cannot be converted to $simpleName: $cause." 64 | } 65 | } 66 | 67 | final case class BodyNotParsed(targetType: ClassTag[_]) extends NotParsed("body", targetType) 68 | final case class ParamNotParsed(name: String, targetType: ClassTag[_]) extends NotParsed(s"param '$name'", targetType) 69 | final case class HeaderNotParsed(name: String, targetType: ClassTag[_]) extends NotParsed(s"header '$name'", targetType) 70 | final case class CookieNotParsed(name: String, targetType: ClassTag[_]) extends NotParsed(s"cookie '$name'", targetType) 71 | } 72 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Input.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.syntax.all._ 4 | import cats.{Eq, Functor} 5 | import com.twitter.finagle.http.{Method, Request, RequestBuilder} 6 | import com.twitter.io.{Buf, Reader} 7 | import shapeless.Witness 8 | 9 | import java.nio.charset.{Charset, StandardCharsets} 10 | import scala.collection.mutable.ListBuffer 11 | 12 | /** An input for [[Endpoint]] that glues two individual pieces together: 13 | * 14 | * - Finagle's [[com.twitter.finagle.http.Request]] needed for evaluating (e.g., `body`, `param`) 15 | * - Finch's route (represented as `Seq[String]`) needed for matching (e.g., `path`) 16 | */ 17 | final case class Input(request: Request, route: List[String]) { 18 | 19 | /** Returns the new `Input` wrapping a given `route`. 20 | */ 21 | def withRoute(route: List[String]): Input = Input(request, route) 22 | 23 | /** Returns the new `Input` wrapping a given payload. This requires the content-type as a first type parameter (won't be inferred). 24 | * 25 | * ``` 26 | * import io.finch._, io.circe._ 27 | * 28 | * val text = Input.post("/").withBody[Text.Plain]("Text Body") 29 | * val json = Input.post("/").withBody[Application.Json](Map("json" -> "object")) 30 | * ``` 31 | * 32 | * Also possible to create chunked inputs passing a stream as an argument. 33 | * 34 | * ``` 35 | * import io.finch._, io.finch.iteratee._, cats.effect.IO, io.iteratee.Enumerator 36 | * import io.finch.circe._, io.circe.generic.auto._ 37 | * 38 | * val enumerateText = Enumerator.enumerate[IO, String]("foo", "bar") 39 | * val text = Input.post("/").withBody[Text.Plain](enumerateText) 40 | * 41 | * val enumerateJson = Enumerate.enumerate[IO, Map[String, String]](Map("foo" - "bar")) 42 | * val json = Input.post("/").withBody[Application.Json](enumerateJson) 43 | * ``` 44 | */ 45 | def withBody[CT <: String]: Input.Body[CT] = new Input.Body[CT](this) 46 | 47 | /** Returns the new `Input` with `headers` amended. 48 | */ 49 | def withHeaders(headers: (String, String)*): Input = { 50 | val copied = Input.copyRequest(request) 51 | headers.foreach { case (k, v) => copied.headerMap.set(k, v) } 52 | 53 | Input(copied, route) 54 | } 55 | 56 | /** Returns the new `Input` wrapping a given `application/x-www-form-urlencoded` payload. 57 | * 58 | * @note 59 | * In addition to media type, this will also set charset to UTF-8. 60 | */ 61 | def withForm(params: (String, String)*): Input = { 62 | val postRequest: Request = RequestBuilder().addFormElement(params: _*).url("http://localhost").buildFormPost() 63 | 64 | withBody[Application.WwwFormUrlencoded](postRequest.content, StandardCharsets.UTF_8) 65 | } 66 | } 67 | 68 | /** Creates an input for [[Endpoint]] from [[com.twitter.finagle.http.Request]]. */ 69 | object Input { 70 | 71 | final private def copyRequest(from: Request): Request = 72 | copyRequestWithReader(from, from.reader) 73 | 74 | final private def copyRequestWithReader(from: Request, reader: Reader[Buf]): Request = { 75 | val to = Request(from.version, from.method, from.uri, reader) 76 | to.setChunked(from.isChunked) 77 | to.content = from.content 78 | from.headerMap.foreach { case (k, v) => to.headerMap.put(k, v) } 79 | 80 | to 81 | } 82 | 83 | /** A helper class that captures the `Content-Type` of the payload. 84 | */ 85 | class Body[CT <: String](i: Input) { 86 | def apply[A](body: A)(implicit e: Encode.Aux[A, CT], w: Witness.Aux[CT]): Input = 87 | apply[A](body, StandardCharsets.UTF_8) 88 | 89 | def apply[A](body: A, charset: Charset)(implicit 90 | e: Encode.Aux[A, CT], 91 | W: Witness.Aux[CT] 92 | ): Input = { 93 | val content = e(body, charset) 94 | val copied = copyRequest(i.request) 95 | 96 | copied.setChunked(false) 97 | copied.content = content 98 | copied.contentType = W.value 99 | copied.contentLength = content.length.toLong 100 | copied.charset = charset.displayName().toLowerCase 101 | 102 | Input(copied, i.route) 103 | } 104 | 105 | def apply[F[_]: Functor, S[_[_], _], A](s: S[F, A])(implicit 106 | S: EncodeStream.Aux[F, S, A, CT], 107 | W: Witness.Aux[CT] 108 | ): F[Input] = apply[F, S, A](s, StandardCharsets.UTF_8) 109 | 110 | def apply[F[_]: Functor, S[_[_], _], A](s: S[F, A], charset: Charset)(implicit 111 | S: EncodeStream.Aux[F, S, A, CT], 112 | W: Witness.Aux[CT] 113 | ): F[Input] = S(s, charset).map { content => 114 | val copied = copyRequestWithReader(i.request, content) 115 | copied.setChunked(true) 116 | copied.contentType = W.value 117 | copied.headerMap.setUnsafe("Transfer-Encoding", "chunked") 118 | copied.charset = charset.displayName().toLowerCase 119 | Input(copied, i.route) 120 | } 121 | } 122 | 123 | implicit val inputEq: Eq[Input] = Eq.fromUniversalEquals 124 | 125 | /** Creates an [[Input]] from a given [[com.twitter.finagle.http.Request]]. */ 126 | def fromRequest(req: Request): Input = { 127 | val p = req.path 128 | 129 | if (p.length == 1) Input(req, Nil) 130 | else { 131 | val route = new ListBuffer[String] 132 | var i, j = 1 // drop the first slash 133 | 134 | while (j < p.length) { 135 | if (p.charAt(j) == '/') { 136 | route += p.substring(i, j) 137 | i = j + 1 138 | } 139 | 140 | j += 1 141 | } 142 | 143 | if (j > i) { 144 | route += p.substring(i, j) 145 | } 146 | 147 | Input(req, route.toList) 148 | } 149 | } 150 | 151 | /** Creates a `GET` input with a given query string (represented as `params`). 152 | */ 153 | def get(path: String, params: (String, String)*): Input = 154 | fromRequest(Request(Method.Get, Request.queryString(path, params: _*))) 155 | 156 | /** Creates a `PUT` input with a given query string (represented as `params`). 157 | */ 158 | def put(path: String, params: (String, String)*): Input = 159 | fromRequest(Request(Method.Put, Request.queryString(path, params: _*))) 160 | 161 | /** Creates a `PATCH` input with a given query string (represented as `params`). 162 | */ 163 | def patch(path: String, params: (String, String)*): Input = 164 | fromRequest(Request(Method.Patch, Request.queryString(path, params: _*))) 165 | 166 | /** Creates a `DELETE` input with a given query string (represented as `params`). 167 | */ 168 | def delete(path: String, params: (String, String)*): Input = 169 | fromRequest(Request(Method.Delete, Request.queryString(path, params: _*))) 170 | 171 | /** Creates a `POST` input with empty payload. 172 | */ 173 | def post(path: String): Input = fromRequest(Request(Method.Post, path)) 174 | } 175 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/LiftReader.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import com.twitter.io.{Buf, Reader} 4 | 5 | /** Create stream `S[F, A]` from [[com.twitter.io.Reader]]. */ 6 | trait LiftReader[S[_[_], _], F[_]] { 7 | 8 | final def apply(reader: Reader[Buf]): S[F, Buf] = apply(reader, identity) 9 | 10 | def apply[A](reader: Reader[Buf], process: Buf => A): S[F, A] 11 | } 12 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/LowPriorityEndpointInstances.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.{MonoidK, SemigroupK} 4 | 5 | private[finch] trait LowPriorityEndpointInstances { 6 | 7 | protected trait EndpointSemigroupK[F[_]] extends SemigroupK[Endpoint[F, *]] { 8 | def combineK[A](x: Endpoint[F, A], y: Endpoint[F, A]): Endpoint[F, A] = 9 | x.coproduct(y) 10 | } 11 | 12 | protected trait EndpointMonoidK[F[_]] extends EndpointSemigroupK[F] with MonoidK[Endpoint[F, *]] { 13 | def empty[A]: Endpoint[F, A] = Endpoint.empty 14 | } 15 | 16 | implicit def endpointMonoidK[F[_]]: MonoidK[Endpoint[F, *]] = 17 | new EndpointMonoidK[F] {} 18 | 19 | } 20 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Outputs.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import com.twitter.finagle.http.Status 4 | 5 | trait Outputs { 6 | 7 | // See https://gist.github.com/vkostyukov/32c84c0c01789425c29a to understand how this list 8 | // is assembled. 9 | 10 | // 2xx 11 | def Ok[A](a: A): Output[A] = Output.payload(a, Status.Ok) // 200 12 | def Created[A](a: A): Output[A] = Output.payload(a, Status.Created) // 201 13 | def Accepted[A]: Output[A] = Output.empty(Status.Accepted) // 202 14 | def NoContent[A]: Output[A] = Output.empty(Status.NoContent) // 204 15 | 16 | // 4xx 17 | def BadRequest(cause: Exception): Output[Nothing] = Output.failure(cause, Status.BadRequest) // 400 18 | def Unauthorized(cause: Exception): Output[Nothing] = 19 | Output.failure(cause, Status.Unauthorized) // 401 20 | def PaymentRequired(cause: Exception): Output[Nothing] = 21 | Output.failure(cause, Status.PaymentRequired) // 402 22 | 23 | def Forbidden(cause: Exception): Output[Nothing] = Output.failure(cause, Status.Forbidden) // 403 24 | def NotFound(cause: Exception): Output[Nothing] = Output.failure(cause, Status.NotFound) // 404 25 | def MethodNotAllowed(cause: Exception): Output[Nothing] = 26 | Output.failure(cause, Status.MethodNotAllowed) // 405 27 | def NotAcceptable(cause: Exception): Output[Nothing] = 28 | Output.failure(cause, Status.NotAcceptable) // 406 29 | def RequestTimeout(cause: Exception): Output[Nothing] = 30 | Output.failure(cause, Status.RequestTimeout) // 408 31 | def Conflict(cause: Exception): Output[Nothing] = Output.failure(cause, Status.Conflict) // 409 32 | def Gone(cause: Exception): Output[Nothing] = Output.failure(cause, Status.Gone) // 410 33 | def LengthRequired(cause: Exception): Output[Nothing] = 34 | Output.failure(cause, Status.LengthRequired) // 411 35 | def PreconditionFailed(cause: Exception): Output[Nothing] = 36 | Output.failure(cause, Status.PreconditionFailed) // 412 37 | def RequestEntityTooLarge(cause: Exception): Output[Nothing] = 38 | Output.failure(cause, Status.RequestEntityTooLarge) // 413 39 | def RequestedRangeNotSatisfiable(cause: Exception): Output[Nothing] = 40 | Output.failure(cause, Status.RequestedRangeNotSatisfiable) // 416 41 | def EnhanceYourCalm(cause: Exception): Output[Nothing] = 42 | Output.failure(cause, Status.EnhanceYourCalm) // 420 43 | def UnprocessableEntity(cause: Exception): Output[Nothing] = 44 | Output.failure(cause, Status.UnprocessableEntity) // 422 45 | def TooManyRequests(cause: Exception): Output[Nothing] = 46 | Output.failure(cause, Status.TooManyRequests) // 429 47 | 48 | // 5xx 49 | def InternalServerError(cause: Exception): Output[Nothing] = 50 | Output.failure(cause, Status.InternalServerError) // 500 51 | def NotImplemented(cause: Exception): Output[Nothing] = 52 | Output.failure(cause, Status.NotImplemented) // 501 53 | def BadGateway(cause: Exception): Output[Nothing] = 54 | Output.failure(cause, Status.BadGateway) // 502 55 | def ServiceUnavailable(cause: Exception): Output[Nothing] = 56 | Output.failure(cause, Status.ServiceUnavailable) // 503 57 | def GatewayTimeout(cause: Exception): Output[Nothing] = 58 | Output.failure(cause, Status.GatewayTimeout) // 504 59 | def InsufficientStorage(cause: Exception): Output[Nothing] = 60 | Output.failure(cause, Status.InsufficientStorage) // 507 61 | } 62 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/ServerSentEvent.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Show 4 | import com.twitter.io.Buf 5 | 6 | import java.nio.charset.Charset 7 | 8 | case class ServerSentEvent[A]( 9 | data: A, 10 | id: Option[String] = None, 11 | event: Option[String] = None, 12 | retry: Option[Long] = None 13 | ) 14 | 15 | object ServerSentEvent { 16 | 17 | private def text(s: String, cs: Charset) = Buf.ByteArray.Owned(s.getBytes(cs.name)) 18 | 19 | implicit def encodeEventStream[A](implicit 20 | A: Show[A] 21 | ): Encode.Aux[ServerSentEvent[A], Text.EventStream] = new Encode[ServerSentEvent[A]] { 22 | 23 | type ContentType = Text.EventStream 24 | 25 | def apply(sse: ServerSentEvent[A], cs: Charset): Buf = { 26 | val dataBuf = text("data:", cs).concat(text(A.show(sse.data), cs)).concat(text("\n", cs)) 27 | val eventType = sse.event.map(e => s"event:$e\n").getOrElse("") 28 | val id = sse.id.map(id => s"id:$id\n").getOrElse("") 29 | val retry = sse.retry.map(retry => s"retry:$retry\n").getOrElse("") 30 | val restBuf = text(eventType + id + retry, cs) 31 | dataBuf.concat(restBuf) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/ToResponse.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.{Applicative, Functor} 4 | import com.twitter.finagle.http.{Response, Status, Version} 5 | import shapeless._ 6 | 7 | import java.nio.charset.Charset 8 | import scala.annotation.implicitNotFound 9 | 10 | /** Represents a conversion from `A` to [[com.twitter.finagle.http.Response]]. */ 11 | trait ToResponse[F[_], A] { 12 | type ContentType 13 | def apply(a: A, cs: Charset): F[Response] 14 | } 15 | 16 | trait ToResponseInstances { 17 | @implicitNotFound("""An Endpoint you're trying to convert into a Finagle service is missing one or more encoders. 18 | 19 | Make sure ${A} is one of the following: 20 | 21 | * A com.twitter.finagle.http.Response 22 | * A value of a type with an io.finch.Encode instance (with the corresponding content-type) 23 | * A coproduct made up of some combination of the above 24 | 25 | See https://github.com/finagle/finch/blob/master/docs/src/main/tut/cookbook.md#fixing-the-toservice-compile-error 26 | """) 27 | type Aux[F[_], A, CT] = ToResponse[F, A] { 28 | type ContentType = CT 29 | } 30 | 31 | def instance[F[_], A, CT](fn: (A, Charset) => F[Response]): Aux[F, A, CT] = 32 | new ToResponse[F, A] { 33 | type ContentType = CT 34 | def apply(a: A, cs: Charset): F[Response] = fn(a, cs) 35 | } 36 | 37 | implicit def responseToResponse[F[_], CT <: String](implicit F: Applicative[F]): Aux[F, Response, CT] = 38 | instance((rep, _) => F.pure(rep)) 39 | 40 | implicit def valueToResponse[F[_], A, CT <: String](implicit 41 | F: Applicative[F], 42 | A: Encode.Aux[A, CT], 43 | CT: Witness.Aux[CT] 44 | ): Aux[F, A, CT] = instance { (a, cs) => 45 | val buf = A(a, cs) 46 | val rep = Response(Version.Http11, Status.Ok) 47 | if (!buf.isEmpty) { 48 | rep.content = buf 49 | rep.headerMap.setUnsafe("Content-Type", CT.value) 50 | } 51 | 52 | F.pure(rep) 53 | } 54 | 55 | implicit def streamToResponse[F[_], S[_[_], _], A, CT <: String](implicit 56 | F: Functor[F], 57 | S: EncodeStream.Aux[F, S, A, CT], 58 | CT: Witness.Aux[CT] 59 | ): Aux[F, S[F, A], CT] = instance { (a, cs) => 60 | F.map(S(a, cs)) { stream => 61 | val rep = Response(Version.Http11, Status.Ok, stream) 62 | rep.headerMap.setUnsafe("Content-Type", CT.value) 63 | rep.headerMap.setUnsafe("Transfer-Encoding", "chunked") 64 | rep 65 | } 66 | } 67 | } 68 | 69 | object ToResponse extends ToResponseInstances { 70 | implicit def coproductToResponse[F[_], C <: Coproduct, CT](implicit 71 | fc: FromCoproduct.Aux[F, C, CT] 72 | ): Aux[F, C, CT] = fc 73 | 74 | final case class Negotiated[F[_], A]( 75 | value: ToResponse[F, A], 76 | error: ToResponse[F, Exception], 77 | acceptable: Boolean = true 78 | ) 79 | 80 | /** Enables server-driven content negotiation with client. 81 | * 82 | * Picks corresponding instance of `ToResponse` according to `Accept` header of a request. 83 | */ 84 | trait Negotiable[F[_], A, CT] { 85 | def apply(accept: List[Accept]): Negotiated[F, A] 86 | } 87 | 88 | object Negotiable { 89 | implicit def coproductToNegotiable[F[_], A, CTH <: String, CTT <: Coproduct](implicit 90 | value: Aux[F, A, CTH], 91 | error: Aux[F, Exception, CTH], 92 | rest: Negotiable[F, A, CTT], 93 | matcher: Accept.Matcher[CTH] 94 | ): Negotiable[F, A, CTH :+: CTT] = 95 | accept => if (matcher(accept)) Negotiated(value, error) else rest(accept) 96 | 97 | implicit def cnilToNegotiable[F[_], A, CTH <: String](implicit 98 | value: Aux[F, A, CTH], 99 | error: Aux[F, Exception, CTH], 100 | matcher: Accept.Matcher[CTH] 101 | ): Negotiable[F, A, CTH :+: CNil] = 102 | accept => Negotiated(value, error, matcher(accept)) 103 | 104 | implicit def singleToNegotiable[F[_], A, CT <: String](implicit 105 | value: Aux[F, A, CT], 106 | error: Aux[F, Exception, CT], 107 | matcher: Accept.Matcher[CT] 108 | ): Negotiable[F, A, CT] = 109 | accept => Negotiated(value, error, matcher(accept)) 110 | } 111 | 112 | trait FromCoproduct[F[_], C <: Coproduct] extends ToResponse[F, C] 113 | object FromCoproduct { 114 | type Aux[F[_], C <: Coproduct, CT] = FromCoproduct[F, C] { 115 | type ContentType = CT 116 | } 117 | 118 | def instance[F[_], C <: Coproduct, CT](fn: (C, Charset) => F[Response]): Aux[F, C, CT] = 119 | new FromCoproduct[F, C] { 120 | type ContentType = CT 121 | def apply(c: C, cs: Charset): F[Response] = fn(c, cs) 122 | } 123 | 124 | implicit def cnilToResponse[F[_], CT](implicit F: Applicative[F]): Aux[F, CNil, CT] = 125 | instance((_, _) => F.pure(Response(Version.Http10, Status.NotFound))) 126 | 127 | implicit def cconsToResponse[F[_], L, R <: Coproduct, CT](implicit 128 | tr: ToResponse.Aux[F, L, CT], 129 | fc: Aux[F, R, CT] 130 | ): Aux[F, L :+: R, CT] = instance { 131 | case (Inl(l), cs) => tr(l, cs) 132 | case (Inr(r), cs) => fc(r, cs) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/ToService.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.Async 4 | import cats.effect.std.Dispatcher 5 | import cats.implicits._ 6 | import com.twitter.finagle.Service 7 | import com.twitter.finagle.http.{Request, Response} 8 | import com.twitter.util.{Future, Promise} 9 | 10 | /** Representation of `Endpoint.Compiled` as Finagle Service 11 | */ 12 | case class ToService[F[_]](compiled: Endpoint.Compiled[F], dispatcher: Dispatcher[F])(implicit F: Async[F]) extends Service[Request, Response] { 13 | def apply(request: Request): Future[Response] = { 14 | val repF = compiled(request).flatMap { case (trc, either) => 15 | Trace.captureIfNeeded(trc) 16 | F.fromEither(either) 17 | } 18 | val rep = new Promise[Response] 19 | val cancel = dispatcher.unsafeRunCancelable(repF.attempt.map(_.fold(rep.setException, rep.setValue))) 20 | rep.setInterruptHandler { case _ => cancel() } 21 | rep 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/Trace.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import com.twitter.util.Local 4 | 5 | import scala.annotation.tailrec 6 | import scala.collection.mutable.ListBuffer 7 | 8 | /** Models a trace of a matched [[Endpoint]]. For example, `/hello/:name`. 9 | * 10 | * @note 11 | * represented as a linked-list-like structure for efficiency. 12 | */ 13 | sealed trait Trace { 14 | 15 | /** Concatenates this and `that` [[Trace]]s. 16 | */ 17 | final def concat(that: Trace): Trace = { 18 | @tailrec 19 | def loop(from: Trace, last: Trace.Segment): Unit = from match { 20 | case Trace.Empty => 21 | last.next = that 22 | case Trace.Segment(p, n) => 23 | val newLast = Trace.Segment(p, Trace.Empty) 24 | last.next = newLast 25 | loop(n, newLast) 26 | } 27 | 28 | this match { 29 | case Trace.Empty => that 30 | case a @ Trace.Segment(_, _) => 31 | that match { 32 | case Trace.Empty => a 33 | case _ => 34 | val result = Trace.Segment(a.path, Trace.Empty) 35 | loop(a.next, result) 36 | result 37 | } 38 | } 39 | } 40 | 41 | /** Converts this [[Trace]] into a linked list of path segments. 42 | */ 43 | final def toList: List[String] = { 44 | @tailrec 45 | def loop(from: Trace, to: ListBuffer[String]): List[String] = from match { 46 | case Trace.Empty => to.toList 47 | case Trace.Segment(path, next) => loop(next, to += path) 48 | } 49 | 50 | loop(this, ListBuffer.empty) 51 | } 52 | 53 | final override def toString: String = toList.mkString("/", "/", "") 54 | } 55 | 56 | object Trace { 57 | private case object Empty extends Trace 58 | final private case class Segment(path: String, var next: Trace) extends Trace 59 | 60 | private class Capture(var trace: Trace) 61 | private val captureLocal = new Local[Capture] 62 | 63 | def empty: Trace = Empty 64 | def segment(s: String): Trace = Segment(s, empty) 65 | 66 | def fromRoute(r: List[String]): Trace = { 67 | var result = empty 68 | var current: Segment = null 69 | 70 | def prepend(segment: Segment): Unit = 71 | if (result == empty) { 72 | result = segment 73 | current = segment 74 | } else { 75 | current.next = segment 76 | current = segment 77 | } 78 | 79 | var rs = r 80 | while (rs.nonEmpty) { 81 | prepend(Segment(rs.head, empty)) 82 | rs = rs.tail 83 | } 84 | 85 | result 86 | } 87 | 88 | /** Within a given context `fn`, capture the [[Trace]] instance under `Trace.captured` for each matched endpoint. 89 | * 90 | * Example: 91 | * 92 | * {{{ 93 | * val foo = Endpoint.lift("foo").toService[Text.Plain] 94 | * Trace.capture { foo(Request()).map(_ => Trace.captured) } 95 | * }}} 96 | */ 97 | def capture[A](fn: => A): A = captureLocal.let(new Capture(empty))(fn) 98 | 99 | /** Retrieve the captured [[Trace]] instance or [[empty]] when run outside of [[Trace.capture]] context. 100 | */ 101 | def captured: Trace = captureLocal() match { 102 | case Some(c) => c.trace 103 | case None => empty 104 | } 105 | 106 | private[finch] def captureIfNeeded(trace: Trace): Unit = captureLocal() match { 107 | case Some(c) => c.trace = trace 108 | case None => // do nothing 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/contentTypes.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import shapeless.Witness 4 | 5 | /** @see 6 | * [[https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types]] 7 | */ 8 | object Application { 9 | type Json = Witness.`"application/json"`.T 10 | type Xml = Witness.`"application/xml"`.T 11 | type AtomXml = Witness.`"application/atom+xml"`.T 12 | type Csv = Witness.`"application/csv"`.T 13 | type Javascript = Witness.`"application/javascript"`.T 14 | type OctetStream = Witness.`"application/octet-stream"`.T 15 | type RssXml = Witness.`"application/rss+xml"`.T 16 | type WwwFormUrlencoded = Witness.`"application/x-www-form-urlencoded"`.T 17 | type Ogg = Witness.`"application/ogg"`.T 18 | } 19 | 20 | /** @see 21 | * [[https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types]] 22 | */ 23 | object Text { 24 | type Plain = Witness.`"text/plain"`.T 25 | type Html = Witness.`"text/html"`.T 26 | type Css = Witness.`"text/css"`.T 27 | type EventStream = Witness.`"text/event-stream"`.T 28 | } 29 | 30 | /** @see 31 | * [[https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types]] 32 | */ 33 | object Image { 34 | type Gif = Witness.`"image/gif"`.T 35 | type Jpeg = Witness.`"image/jpeg"`.T 36 | type Png = Witness.`"image/png"`.T 37 | type Svg = Witness.`"image/svg+xml"`.T 38 | } 39 | 40 | /** @see 41 | * [[https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types]] 42 | */ 43 | object Audio { 44 | type Wave = Witness.`"audio/wave"`.T 45 | type Wav = Witness.`"audio/wav"`.T 46 | type Webm = Witness.`"audio/webm"`.T 47 | type Ogg = Witness.`"audio/ogg"`.T 48 | } 49 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/endpoint/body.scala: -------------------------------------------------------------------------------- 1 | package io.finch.endpoint 2 | 3 | import cats.effect.Sync 4 | import com.twitter.io.{Buf, Reader} 5 | import io.finch._ 6 | import io.finch.internal._ 7 | 8 | import java.nio.charset.{Charset, StandardCharsets} 9 | import scala.reflect.ClassTag 10 | 11 | abstract private[finch] class FullBody[F[_], A] extends Endpoint[F, A] { 12 | 13 | protected def F: Sync[F] 14 | protected def missing: F[Output[A]] 15 | protected def present(contentType: String, content: Buf, cs: Charset): F[Output[A]] 16 | 17 | final def apply(input: Input): EndpointResult[F, A] = 18 | if (input.request.isChunked) EndpointResult.NotMatched[F] 19 | else { 20 | val output = F.defer { 21 | val contentLength = input.request.contentLengthOrNull 22 | if (contentLength == null || contentLength == "0") missing 23 | else 24 | present( 25 | input.request.mediaTypeOrEmpty, 26 | input.request.content, 27 | input.request.charsetOrUtf8 28 | ) 29 | } 30 | 31 | EndpointResult.Matched(input, Trace.empty, output) 32 | } 33 | } 34 | 35 | private[finch] object FullBody { 36 | 37 | trait PreparedBody[F[_], A, B] { _: FullBody[F, B] => 38 | protected def prepare(a: A): B 39 | } 40 | 41 | trait Required[F[_], A] extends PreparedBody[F, A, A] { _: FullBody[F, A] => 42 | protected def prepare(a: A): A = a 43 | protected def missing: F[Output[A]] = F.raiseError(Error.BodyNotPresent) 44 | } 45 | 46 | trait Optional[F[_], A] extends PreparedBody[F, A, Option[A]] { _: FullBody[F, Option[A]] => 47 | protected def prepare(a: A): Option[A] = Some(a) 48 | protected def missing: F[Output[Option[A]]] = F.pure(Output.None) 49 | } 50 | } 51 | 52 | abstract private[finch] class Body[F[_], A, B, CT](implicit 53 | dd: Decode.Dispatchable[A, CT], 54 | ct: ClassTag[A], 55 | protected val F: Sync[F] 56 | ) extends FullBody[F, B] 57 | with FullBody.PreparedBody[F, A, B] { 58 | 59 | protected def present(contentType: String, content: Buf, cs: Charset): F[Output[B]] = 60 | dd(contentType, content, cs) match { 61 | case Right(s) => F.pure(Output.payload(prepare(s))) 62 | case Left(e) => F.raiseError(Error.BodyNotParsed(ct).initCause(e)) 63 | } 64 | 65 | final override def toString: String = "body" 66 | } 67 | 68 | abstract private[finch] class BinaryBody[F[_], A](implicit protected val F: Sync[F]) extends FullBody[F, A] with FullBody.PreparedBody[F, Array[Byte], A] { 69 | 70 | protected def present(contentType: String, content: Buf, cs: Charset): F[Output[A]] = 71 | F.pure(Output.payload(prepare(content.asByteArray))) 72 | 73 | final override def toString: String = "binaryBody" 74 | } 75 | 76 | abstract private[finch] class StringBody[F[_], A](implicit protected val F: Sync[F]) extends FullBody[F, A] with FullBody.PreparedBody[F, String, A] { 77 | 78 | protected def present(contentType: String, content: Buf, cs: Charset): F[Output[A]] = 79 | F.pure(Output.payload(prepare(content.asString(cs)))) 80 | 81 | final override def toString: String = "stringBody" 82 | } 83 | 84 | abstract private[finch] class ChunkedBody[F[_], S[_[_], _], A] extends Endpoint[F, S[F, A]] { 85 | 86 | protected def F: Sync[F] 87 | protected def prepare(r: Reader[Buf], cs: Charset): Output[S[F, A]] 88 | 89 | final def apply(input: Input): EndpointResult[F, S[F, A]] = 90 | if (!input.request.isChunked) EndpointResult.NotMatched[F] 91 | else 92 | EndpointResult.Matched( 93 | input, 94 | Trace.empty, 95 | F.delay(prepare(input.request.reader, input.request.charsetOrUtf8)) 96 | ) 97 | } 98 | 99 | final private[finch] class BinaryBodyStream[F[_], S[_[_], _]](implicit 100 | LR: LiftReader[S, F], 101 | protected val F: Sync[F] 102 | ) extends ChunkedBody[F, S, Array[Byte]] 103 | with (Buf => Array[Byte]) { 104 | 105 | def apply(buf: Buf): Array[Byte] = buf.asByteArray 106 | 107 | protected def prepare(r: Reader[Buf], cs: Charset): Output[S[F, Array[Byte]]] = 108 | Output.payload(LR(r, this)) 109 | 110 | override def toString: String = "binaryBodyStream" 111 | } 112 | 113 | final private[finch] class StringBodyStream[F[_], S[_[_], _]](implicit 114 | LR: LiftReader[S, F], 115 | protected val F: Sync[F] 116 | ) extends ChunkedBody[F, S, String] 117 | with (Buf => String) { 118 | 119 | def apply(buf: Buf): String = buf.asString(StandardCharsets.UTF_8) 120 | 121 | protected def prepare(r: Reader[Buf], cs: Charset): Output[S[F, String]] = cs match { 122 | case StandardCharsets.UTF_8 => Output.payload(LR(r, this)) 123 | case _ => Output.payload(LR(r, _.asString(cs))) 124 | } 125 | 126 | override def toString: String = "stringBodyStream" 127 | } 128 | 129 | final private[finch] class BodyStream[F[_], S[_[_], _], A, CT <: String](implicit 130 | protected val F: Sync[F], 131 | LR: LiftReader[S, F], 132 | A: DecodeStream.Aux[S, F, A, CT] 133 | ) extends ChunkedBody[F, S, A] { 134 | 135 | protected def prepare(r: Reader[Buf], cs: Charset): Output[S[F, A]] = 136 | Output.payload(A(LR(r), cs)) 137 | 138 | override def toString: String = "bodyStream" 139 | } 140 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/endpoint/cookie.scala: -------------------------------------------------------------------------------- 1 | package io.finch.endpoint 2 | 3 | import cats.effect.Sync 4 | import com.twitter.finagle.http.{Cookie => FinagleCookie} 5 | import io.finch._ 6 | 7 | abstract private[finch] class Cookie[F[_], A](name: String)(implicit 8 | protected val F: Sync[F] 9 | ) extends Endpoint[F, A] { 10 | 11 | protected def missing(name: String): F[Output[A]] 12 | protected def present(value: FinagleCookie): F[Output[A]] 13 | 14 | def apply(input: Input): EndpointResult[F, A] = { 15 | val output = F.defer { 16 | input.request.cookies.get(name) match { 17 | case None => missing(name) 18 | case Some(value) => present(value) 19 | } 20 | } 21 | 22 | EndpointResult.Matched(input, Trace.empty, output) 23 | } 24 | 25 | final override def toString: String = s"cookie($name)" 26 | } 27 | 28 | private[finch] object Cookie { 29 | 30 | trait Optional[F[_]] { _: Cookie[F, Option[FinagleCookie]] => 31 | protected def missing(name: String): F[Output[Option[FinagleCookie]]] = F.pure(Output.None) 32 | protected def present(value: FinagleCookie): F[Output[Option[FinagleCookie]]] = 33 | F.pure(Output.payload(Some(value))) 34 | } 35 | 36 | trait Required[F[_]] { _: Cookie[F, FinagleCookie] => 37 | protected def missing(name: String): F[Output[FinagleCookie]] = 38 | F.raiseError(Error.CookieNotPresent(name)) 39 | protected def present(value: FinagleCookie): F[Output[FinagleCookie]] = 40 | F.pure(Output.payload(value)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/endpoint/endpoint.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | package endpoint 3 | 4 | import cats.Applicative 5 | import cats.effect.{Resource, Sync} 6 | import cats.syntax.all._ 7 | import com.twitter.finagle.http.{Method => FinagleMethod} 8 | import com.twitter.io.Buf 9 | import shapeless.HNil 10 | 11 | import java.io.InputStream 12 | 13 | private[finch] class FromInputStream[F[_]](stream: Resource[F, InputStream])(implicit F: Sync[F]) extends Endpoint[F, Buf] { 14 | private def readLoop(left: Buf, stream: InputStream): F[Buf] = F.defer { 15 | for { 16 | buffer <- F.delay(new Array[Byte](1024)) 17 | n <- F.blocking(stream.read(buffer)) 18 | buf <- if (n == -1) F.pure(left) else readLoop(left.concat(Buf.ByteArray.Owned(buffer, 0, n)), stream) 19 | } yield buf 20 | } 21 | 22 | final def apply(input: Input): Endpoint.Result[F, Buf] = 23 | EndpointResult.Matched( 24 | input, 25 | Trace.empty, 26 | stream.use(s => readLoop(Buf.Empty, s).map(buf => Output.payload(buf))) 27 | ) 28 | } 29 | 30 | private[finch] class Asset[F[_]](path: String)(implicit F: Applicative[F]) extends Endpoint[F, HNil] { 31 | final def apply(input: Input): Endpoint.Result[F, HNil] = { 32 | val req = input.request 33 | if (req.method != FinagleMethod.Get || req.path != path) EndpointResult.NotMatched[F] 34 | else 35 | EndpointResult.Matched( 36 | input.withRoute(Nil), 37 | Trace.fromRoute(input.route), 38 | F.pure(Output.HNil) 39 | ) 40 | } 41 | 42 | final override def toString: String = s"GET /$path" 43 | } 44 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/endpoint/header.scala: -------------------------------------------------------------------------------- 1 | package io.finch.endpoint 2 | 3 | import cats.Id 4 | import cats.effect.Sync 5 | import io.finch._ 6 | 7 | import scala.reflect.ClassTag 8 | 9 | abstract private[finch] class Header[F[_], G[_], A](name: String)(implicit 10 | d: DecodeEntity[A], 11 | tag: ClassTag[A], 12 | protected val F: Sync[F] 13 | ) extends Endpoint[F, G[A]] { self => 14 | 15 | protected def missing(name: String): F[Output[G[A]]] 16 | protected def present(value: A): G[A] 17 | 18 | final def apply(input: Input): EndpointResult[F, G[A]] = { 19 | val output: F[Output[G[A]]] = F.defer { 20 | input.request.headerMap.getOrNull(name) match { 21 | case null => missing(name) 22 | case value => 23 | d(value) match { 24 | case Right(s) => F.pure(Output.payload(present(s))) 25 | case Left(e) => F.raiseError(Error.HeaderNotParsed(name, tag).initCause(e)) 26 | } 27 | } 28 | } 29 | 30 | EndpointResult.Matched(input, Trace.empty, output) 31 | } 32 | 33 | final override def toString: String = s"header($name)" 34 | } 35 | 36 | private[finch] object Header { 37 | 38 | trait Required[F[_], A] { _: Header[F, Id, A] => 39 | protected def missing(name: String): F[Output[A]] = 40 | F.raiseError(Error.HeaderNotPresent(name)) 41 | protected def present(value: A): Id[A] = value 42 | } 43 | 44 | trait Optional[F[_], A] { _: Header[F, Option, A] => 45 | protected def missing(name: String): F[Output[Option[A]]] = 46 | F.pure(Output.None) 47 | protected def present(value: A): Option[A] = Some(value) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/endpoint/method.scala: -------------------------------------------------------------------------------- 1 | package io.finch.endpoint 2 | 3 | import com.twitter.finagle.http.{Method => FinagleMethod} 4 | import io.finch._ 5 | 6 | private[finch] class Method[F[_], A](m: FinagleMethod, e: Endpoint[F, A]) extends Endpoint.Mappable[F, A] { self => 7 | 8 | final def apply(input: Input): EndpointResult[F, A] = 9 | if (input.request.method == m) e(input) 10 | else 11 | e(input) match { 12 | case EndpointResult.Matched(_, _, _) => EndpointResult.NotMatched.MethodNotAllowed(m :: Nil) 13 | case skipped => skipped 14 | } 15 | 16 | final override def toString: String = s"${m.toString.toUpperCase} /${e.toString}" 17 | } 18 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/endpoint/param.scala: -------------------------------------------------------------------------------- 1 | package io.finch.endpoint 2 | 3 | import cats.Id 4 | import cats.data.NonEmptyList 5 | import cats.effect.Sync 6 | import cats.syntax.all._ 7 | import io.finch._ 8 | 9 | import scala.reflect.ClassTag 10 | 11 | abstract private[finch] class Param[F[_], G[_], A](name: String)(implicit 12 | d: DecodeEntity[A], 13 | tag: ClassTag[A], 14 | protected val F: Sync[F] 15 | ) extends Endpoint[F, G[A]] { self => 16 | 17 | protected def missing(name: String): F[Output[G[A]]] 18 | protected def present(value: A): G[A] 19 | 20 | final def apply(input: Input): EndpointResult[F, G[A]] = { 21 | val output: F[Output[G[A]]] = F.defer { 22 | input.request.params.get(name) match { 23 | case None => missing(name) 24 | case Some(value) => 25 | d(value) match { 26 | case Right(s) => F.pure(Output.payload(present(s))) 27 | case Left(e) => F.raiseError(Error.ParamNotParsed(name, tag).initCause(e)) 28 | } 29 | } 30 | } 31 | 32 | EndpointResult.Matched(input, Trace.empty, output) 33 | } 34 | 35 | final override def toString: String = s"param($name)" 36 | } 37 | 38 | private[finch] object Param { 39 | 40 | trait Required[F[_], A] { _: Param[F, Id, A] => 41 | protected def missing(name: String): F[Output[A]] = 42 | F.raiseError(Error.ParamNotPresent(name)) 43 | protected def present(a: A): Id[A] = a 44 | } 45 | 46 | trait Optional[F[_], A] { _: Param[F, Option, A] => 47 | protected def missing(name: String): F[Output[Option[A]]] = F.pure(Output.None) 48 | protected def present(a: A): Option[A] = Some(a) 49 | } 50 | } 51 | 52 | abstract private[finch] class Params[F[_], G[_], A](name: String)(implicit 53 | d: DecodeEntity[A], 54 | tag: ClassTag[A], 55 | protected val F: Sync[F] 56 | ) extends Endpoint[F, G[A]] { 57 | 58 | protected def missing(name: String): F[Output[G[A]]] 59 | protected def present(value: Iterable[A]): G[A] 60 | 61 | final def apply(input: Input): EndpointResult[F, G[A]] = { 62 | val output: F[Output[G[A]]] = F.defer { 63 | input.request.params.getAll(name) match { 64 | case value if value.isEmpty => missing(name) 65 | case value => 66 | val (errors, decoded) = value.toList.map(d.apply).separate 67 | NonEmptyList.fromList(errors) match { 68 | case None => F.pure(Output.payload(present(decoded))) 69 | case Some(es) => F.raiseError(Errors(es.map(Error.ParamNotParsed(name, tag).initCause(_).asInstanceOf[Error]))) 70 | } 71 | } 72 | } 73 | 74 | EndpointResult.Matched(input, Trace.empty, output) 75 | } 76 | 77 | final override def toString: String = s"params($name)" 78 | } 79 | 80 | private[finch] object Params { 81 | 82 | trait AllowEmpty[F[_], A] { _: Params[F, List, A] => 83 | protected def missing(name: String): F[Output[List[A]]] = F.pure(Output.payload(Nil)) 84 | protected def present(value: Iterable[A]): List[A] = value.toList 85 | } 86 | 87 | trait NonEmpty[F[_], A] { _: Params[F, NonEmptyList, A] => 88 | protected def missing(name: String): F[Output[NonEmptyList[A]]] = 89 | F.raiseError(Error.ParamNotPresent(name)) 90 | protected def present(value: Iterable[A]): NonEmptyList[A] = 91 | NonEmptyList.fromListUnsafe(value.toList) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/endpoint/path.scala: -------------------------------------------------------------------------------- 1 | package io.finch.endpoint 2 | 3 | import cats.Applicative 4 | import io.finch._ 5 | import io.netty.handler.codec.http.QueryStringDecoder 6 | import shapeless.HNil 7 | 8 | import scala.reflect.ClassTag 9 | 10 | private[finch] class MatchPath[F[_]](s: String)(implicit 11 | F: Applicative[F] 12 | ) extends Endpoint[F, HNil] { 13 | final def apply(input: Input): EndpointResult[F, HNil] = input.route match { 14 | case `s` :: rest => 15 | EndpointResult.Matched( 16 | input.withRoute(rest), 17 | Trace.segment(s), 18 | F.pure(Output.HNil) 19 | ) 20 | case _ => EndpointResult.NotMatched[F] 21 | } 22 | 23 | final override def toString: String = s 24 | } 25 | 26 | private[finch] class ExtractPath[F[_], A](implicit 27 | d: DecodePath[A], 28 | ct: ClassTag[A], 29 | F: Applicative[F] 30 | ) extends Endpoint[F, A] { 31 | final def apply(input: Input): EndpointResult[F, A] = input.route match { 32 | case s :: rest => 33 | d(QueryStringDecoder.decodeComponent(s)) match { 34 | case Some(a) => 35 | EndpointResult.Matched( 36 | input.withRoute(rest), 37 | Trace.segment(toString), 38 | F.pure(Output.payload(a)) 39 | ) 40 | case _ => EndpointResult.NotMatched[F] 41 | } 42 | case _ => EndpointResult.NotMatched[F] 43 | } 44 | 45 | final override lazy val toString: String = s":${ct.runtimeClass.getSimpleName.toLowerCase}" 46 | } 47 | 48 | private[finch] class ExtractPaths[F[_], A](implicit 49 | d: DecodePath[A], 50 | ct: ClassTag[A], 51 | F: Applicative[F] 52 | ) extends Endpoint[F, List[A]] { 53 | final def apply(input: Input): EndpointResult[F, List[A]] = EndpointResult.Matched( 54 | input.copy(route = Nil), 55 | Trace.segment(toString), 56 | F.pure(Output.payload(input.route.flatMap(p => d(QueryStringDecoder.decodeComponent(p)).toList))) 57 | ) 58 | 59 | final override lazy val toString: String = s":${ct.runtimeClass.getSimpleName.toLowerCase}*" 60 | } 61 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/internal/DummyExecutionContext.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | import scala.concurrent.ExecutionContext 4 | 5 | object DummyExecutionContext extends ExecutionContext { 6 | def execute(runnable: Runnable): Unit = runnable.run() 7 | def reportFailure(cause: Throwable): Unit = throw new NotImplementedError() 8 | } 9 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/internal/Mapper.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | import cats.syntax.functor._ 4 | import cats.{Monad, MonadThrow} 5 | import com.twitter.finagle.http.Response 6 | import io.finch.{Endpoint, Output} 7 | import shapeless.HNil 8 | import shapeless.ops.function.FnToProduct 9 | 10 | /** A type class that allows the [[Endpoint]] to be mapped to either `A => B` or `A => Future[B]`. 11 | * @groupname LowPriorityMapper Low Priority Mapper Conversions 12 | * @groupprio LowPriorityMapper 0 13 | * @groupname HighPriorityMapper High priority mapper conversions 14 | * @groupprio HighPriorityMapper 1 15 | */ 16 | trait Mapper[F[_], A] { 17 | type Out 18 | 19 | /** @param e 20 | * The endpoint to map 21 | * @tparam X 22 | * Hack to stop the compiler from converting this to a SAM 23 | * @return 24 | * An endpoint that returns an `Out` 25 | */ 26 | def apply[X](e: Endpoint[F, A]): Endpoint[F, Out] 27 | } 28 | 29 | private[finch] trait LowPriorityMapperConversions { 30 | type Aux[F[_], A, B] = Mapper[F, A] { 31 | type Out = B 32 | } 33 | 34 | def instance[F[_], A, B](f: Endpoint[F, A] => Endpoint[F, B]): Aux[F, A, B] = 35 | new Mapper[F, A] { 36 | type Out = B 37 | def apply[X](e: Endpoint[F, A]): Endpoint[F, Out] = f(e) 38 | } 39 | 40 | /** @group LowPriorityMapper */ 41 | implicit def mapperFromOutputFunction[F[_]: MonadThrow, A, B](f: A => Output[B]): Aux[F, A, B] = 42 | instance(_.mapOutput(f)) 43 | 44 | /** @group LowPriorityMapper */ 45 | implicit def mapperFromResponseFunction[F[_]: MonadThrow, A](f: A => Response): Aux[F, A, Response] = 46 | instance(_.mapOutput(f.andThen(r => Output.payload(r, r.status)))) 47 | } 48 | 49 | private[finch] trait HighPriorityMapperConversions extends LowPriorityMapperConversions { 50 | 51 | /** @group HighPriorityMapper */ 52 | implicit def mapperFromOutputHFunction[F[_]: MonadThrow, A, B, FN, OB](f: FN)(implicit 53 | ftp: FnToProduct.Aux[FN, A => OB], 54 | ev: OB <:< Output[B] 55 | ): Aux[F, A, B] = 56 | instance(_.mapOutput(value => ev(ftp(f)(value)))) 57 | 58 | /** @group HighPriorityMapper */ 59 | implicit def mapperFromResponseHFunction[F[_]: MonadThrow, A, FN, R](f: FN)(implicit 60 | ftp: FnToProduct.Aux[FN, A => R], 61 | ev: R <:< Response 62 | ): Aux[F, A, Response] = instance(_.mapOutput { value => 63 | val r = ev(ftp(f)(value)) 64 | Output.payload(r, r.status) 65 | }) 66 | 67 | /** @group HighPriorityMapper */ 68 | implicit def mapperFromOutputValue[F[_]: MonadThrow, A](o: => Output[A]): Aux[F, HNil, A] = 69 | instance(_.mapOutput(_ => o)) 70 | 71 | /** @group HighPriorityMapper */ 72 | implicit def mapperFromResponseValue[F[_]: MonadThrow](r: => Response): Aux[F, HNil, Response] = 73 | instance(_.mapOutput(_ => Output.payload(r, r.status))) 74 | 75 | implicit def mapperFromKindToEffectOutputFunction[A, B, F[_]: Monad](f: A => F[Output[B]]): Aux[F, A, B] = 76 | instance(_.mapOutputAsync(a => f(a))) 77 | 78 | implicit def mapperFromKindToEffectOutputValue[A, B, F[_]: Monad](f: => F[Output[B]]): Aux[F, A, B] = 79 | instance(_.mapOutputAsync(_ => f)) 80 | 81 | implicit def mapperFromKindToEffectResponseFunction[A, F[_]: Monad](f: A => F[Response]): Aux[F, A, Response] = 82 | instance(_.mapOutputAsync(f.andThen(_.map(r => Output.payload(r, r.status))))) 83 | 84 | implicit def mapperFromKindToEffectResponseValue[A, F[_]: Monad](f: => F[Response]): Aux[F, A, Response] = 85 | instance(_.mapOutputAsync(_ => f.map(r => Output.payload(r, r.status)))) 86 | } 87 | 88 | object Mapper extends HighPriorityMapperConversions { 89 | 90 | implicit def mapperFromKindOutputHFunction[F[_]: Monad, A, B, FN, FOB](f: FN)(implicit 91 | ftp: FnToProduct.Aux[FN, A => FOB], 92 | ev: FOB <:< F[Output[B]] 93 | ): Aux[F, A, B] = 94 | instance(_.mapOutputAsync(a => ev(ftp(f)(a)))) 95 | 96 | implicit def mapperFromKindResponseHFunction[F[_]: Monad, A, FN, FR](f: FN)(implicit 97 | ftp: FnToProduct.Aux[FN, A => FR], 98 | ev: FR <:< F[Response] 99 | ): Aux[F, A, Response] = instance(_.mapOutputAsync { value => 100 | ev(ftp(f)(value)).map(r => Output.payload(r, r.status)) 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/internal/PairJoin.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | import shapeless.ops.adjoin.Adjoin 4 | import shapeless.{::, DepFn2, HNil} 5 | 6 | /** We need a version of `Adjoin` that provides slightly different behavior in the case of singleton results (we simply return the value, not a singleton 7 | * `HList`). 8 | * @groupname LowPriorityPair Low priority `PairAdjoin` 9 | * @groupprio LowPriorityPair 0 10 | */ 11 | trait PairAdjoin[A, B] extends DepFn2[A, B] 12 | 13 | private[finch] trait LowPriorityPairAdjoin { 14 | type Aux[A, B, Out0] = PairAdjoin[A, B] { type Out = Out0 } 15 | 16 | /** @group LowPriorityPair 17 | */ 18 | implicit def pairAdjoin[A, B, Out0](implicit 19 | adjoin: Adjoin.Aux[A :: B :: HNil, Out0] 20 | ): Aux[A, B, Out0] = 21 | new PairAdjoin[A, B] { 22 | type Out = Out0 23 | 24 | def apply(a: A, b: B): Out0 = adjoin(a :: b :: HNil) 25 | } 26 | } 27 | 28 | object PairAdjoin extends LowPriorityPairAdjoin { 29 | implicit def singletonPairAdjoin[A, B, C](implicit 30 | adjoin: Adjoin.Aux[A :: B :: HNil, C :: HNil] 31 | ): Aux[A, B, C] = new PairAdjoin[A, B] { 32 | type Out = C 33 | 34 | def apply(a: A, b: B): C = adjoin(a :: b :: HNil).head 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/internal/ParseNumber.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | abstract class ParseNumber[@specialized(Int, Long) A] { 4 | 5 | protected def max: Long 6 | protected def min: Long 7 | protected def prepare(a: Long): A 8 | 9 | // adopted from Java's Long.parseLong 10 | // scalastyle:off return 11 | final def apply(s: String): Option[A] = { 12 | var negative = false 13 | var limit = -max 14 | var result = 0L 15 | 16 | var i = 0 17 | if (s.length > 0) { 18 | val firstChar = s.charAt(0) 19 | if (firstChar < '0') { 20 | if (firstChar == '-') { 21 | negative = true 22 | limit = min 23 | } else if (firstChar != '+') return None 24 | 25 | if (s.length == 1) return None 26 | 27 | i += 1 28 | } 29 | 30 | // skip zeros 31 | while (i < s.length && s.charAt(i) == '0') i += 1 32 | 33 | val mulMin = limit / 10L 34 | 35 | while (i < s.length) { 36 | val c = s.charAt(i) 37 | if ('0' <= c && c <= '9') { 38 | if (result < mulMin) return None 39 | result = result * 10L 40 | val digit = c - '0' 41 | if (result < limit + digit) return None 42 | result = result - digit 43 | } else return None 44 | 45 | i += 1 46 | } 47 | } else return None 48 | 49 | Some(prepare(if (negative) result else -result)) 50 | } 51 | // scalastyle:on return 52 | } 53 | 54 | object parseInt extends ParseNumber[Int] { 55 | protected def min: Long = Int.MinValue 56 | protected def max: Long = Int.MaxValue 57 | protected def prepare(a: Long): Int = a.toInt 58 | } 59 | 60 | object parseLong extends ParseNumber[Long] { 61 | protected def min: Long = Long.MinValue 62 | protected def max: Long = Long.MaxValue 63 | protected def prepare(a: Long): Long = a 64 | } 65 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/internal/currentTime.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | import java.time.format.DateTimeFormatter 4 | import java.time.{Instant, ZoneId} 5 | import java.util.Locale 6 | 7 | object currentTime { 8 | private val formatter: DateTimeFormatter = 9 | DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz").withLocale(Locale.ENGLISH).withZone(ZoneId.of("GMT")) 10 | 11 | private class Last(var millis: Long, var header: String) 12 | 13 | private val last = new ThreadLocal[Last] { 14 | override def initialValue: Last = new Last(0, "") 15 | } 16 | 17 | def apply(): String = { 18 | val local = last.get() 19 | val time = System.currentTimeMillis() 20 | 21 | if (time - local.millis > 1000) { 22 | local.millis = time 23 | local.header = formatter.format(Instant.ofEpochMilli(time)) 24 | } 25 | 26 | local.header 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/internal/newLine.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | import com.twitter.io.Buf 4 | 5 | import java.nio.charset.{Charset, StandardCharsets} 6 | 7 | object newLine { 8 | 9 | private def fromCharset(cs: Charset): Buf = Buf.ByteArray.Owned("\n".getBytes(cs)) 10 | 11 | private val ascii = fromCharset(StandardCharsets.US_ASCII) 12 | private val utf16be = fromCharset(StandardCharsets.UTF_16BE) 13 | private val utf16le = fromCharset(StandardCharsets.UTF_16LE) 14 | private val utf16 = fromCharset(StandardCharsets.UTF_16) 15 | private val utf32 = fromCharset(Utf32) 16 | 17 | final def apply(cs: Charset): Buf = cs match { 18 | case StandardCharsets.UTF_8 => ascii 19 | case StandardCharsets.US_ASCII => ascii 20 | case StandardCharsets.ISO_8859_1 => ascii 21 | case StandardCharsets.UTF_16 => utf16 22 | case StandardCharsets.UTF_16BE => utf16be 23 | case StandardCharsets.UTF_16LE => utf16le 24 | case Utf32 => utf32 25 | case _ => fromCharset(cs) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/internal/package.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.Async 4 | import com.twitter.finagle.http.{Fields, Message} 5 | import com.twitter.io.Buf 6 | import com.twitter.util.Future 7 | import com.twitter.util.Return 8 | import com.twitter.util.Throw 9 | 10 | import java.nio.ByteBuffer 11 | import java.nio.charset.{Charset, StandardCharsets} 12 | 13 | /** This package contains an internal-use only type-classes and utilities that power Finch's API. 14 | * 15 | * It's not recommended to use any of the internal API directly, since it might change without any deprecation cycles. 16 | */ 17 | package object internal { 18 | 19 | @inline final private[this] val someTrue: Option[Boolean] = Some(true) 20 | @inline final private[this] val someFalse: Option[Boolean] = Some(false) 21 | 22 | // Missing in StandardCharsets. 23 | val Utf32: Charset = Charset.forName("UTF-32") 24 | 25 | /** Enriches any string with fast `tooX` conversions. 26 | */ 27 | implicit class TooFastString(val s: String) extends AnyVal { 28 | 29 | /** Converts this string to the optional boolean value. 30 | */ 31 | final def tooBoolean: Option[Boolean] = s match { 32 | case "true" => someTrue 33 | case "false" => someFalse 34 | case _ => None 35 | } 36 | 37 | /** Converts this string to the optional integer value. The maximum allowed length for a number string is 32. 38 | */ 39 | final def tooInt: Option[Int] = 40 | if (s.length == 0 || s.length > 32) None 41 | else parseInt(s) 42 | 43 | /** Converts this string to the optional long value. The maximum allowed length for a number string is 32. 44 | */ 45 | final def tooLong: Option[Long] = 46 | if (s.length == 0 || s.length > 32) None 47 | else parseLong(s) 48 | } 49 | 50 | implicit class HttpMessage(val self: Message) extends AnyVal { 51 | 52 | // Returns message's content length or null. 53 | def contentLengthOrNull: String = self.headerMap.getOrNull(Fields.ContentLength) 54 | 55 | // Returns message's media type or empty string. 56 | def mediaTypeOrEmpty: String = { 57 | val ct = self.headerMap.getOrNull(Fields.ContentType) 58 | if (ct == null) "" 59 | else { 60 | val semi = ct.indexOf(';') 61 | if (semi == -1) ct 62 | else ct.substring(0, semi) 63 | } 64 | } 65 | 66 | // Returns message's charset or UTF-8 if it's not defined. 67 | def charsetOrUtf8: Charset = { 68 | val contentType = self.headerMap.getOrNull(Fields.ContentType) 69 | 70 | if (contentType == null) StandardCharsets.UTF_8 71 | else { 72 | val charsetEq = contentType.indexOf("charset=") 73 | if (charsetEq == -1) StandardCharsets.UTF_8 74 | else { 75 | val from = charsetEq + "charset=".length 76 | val semi = contentType.indexOf(';', from) 77 | val to = if (semi == -1) contentType.length else semi 78 | Charset.forName(contentType.substring(from, to)) 79 | } 80 | } 81 | } 82 | } 83 | 84 | implicit class HttpContent(val self: Buf) extends AnyVal { 85 | // Returns content as ByteArray (tries to avoid copying). 86 | def asByteArrayWithBeginAndEnd: (Array[Byte], Int, Int) = { 87 | // Finagle guarantees to have the payload on heap when it enters the 88 | // user land. With a cost of a tuple allocation we're making this agnostic 89 | // to the underlying Netty version. 90 | val Buf.ByteArray.Owned(array, begin, end) = Buf.ByteArray.coerce(self) 91 | (array, begin, end) 92 | } 93 | 94 | // Returns content as ByteBuffer (tries to avoid copying). 95 | def asByteBuffer: ByteBuffer = { 96 | val (array, begin, end) = asByteArrayWithBeginAndEnd 97 | ByteBuffer.wrap(array, begin, end - begin) 98 | } 99 | 100 | // Returns content as ByteArray (tries to avoid copying). 101 | def asByteArray: Array[Byte] = asByteArrayWithBeginAndEnd match { 102 | case (array, begin, end) if begin == 0 && end == array.length => array 103 | case (array, begin, end) => 104 | val result = new Array[Byte](end - begin) 105 | System.arraycopy(array, begin, result, 0, end - begin) 106 | 107 | result 108 | } 109 | 110 | // Returns content as String (tries to avoid copying). 111 | def asString(cs: Charset): String = { 112 | val (array, begin, end) = asByteArrayWithBeginAndEnd 113 | new String(array, begin, end - begin, cs.name) 114 | } 115 | } 116 | 117 | implicit class TwitterFutureConverter[A](val f: Future[A]) extends AnyVal { 118 | def toAsync[F[_]](implicit F: Async[F]): F[A] = 119 | F.async_ { cb => 120 | f.respond { 121 | case Return(r) => cb(Right(r)) 122 | case Throw(t) => cb(Left(t)) 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /core/src/main/scala/io/finch/package.scala: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import cats.effect.IO 4 | 5 | /** This is a root package of the Finch library, which provides an immutable layer of functions and types atop of Finagle for writing lightweight HTTP services. 6 | */ 7 | package object finch extends Outputs { 8 | 9 | object catsEffect extends EndpointModule[IO] 10 | 11 | } 12 | -------------------------------------------------------------------------------- /core/src/test/resources/test.txt: -------------------------------------------------------------------------------- 1 | foo bar baz 2 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/BodySpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.SyncIO 4 | import com.twitter.finagle.http.Request 5 | import com.twitter.io.Buf 6 | import io.finch.data.Foo 7 | import shapeless.{:+:, CNil} 8 | 9 | import java.nio.charset.Charset 10 | 11 | class BodySpec extends FinchSpec[SyncIO] { 12 | 13 | private class EvalDecode[A](d: Decode.Text[A]) extends Decode[A] { 14 | type ContentType = Text.Plain 15 | 16 | @volatile private var e = false 17 | 18 | def apply(b: Buf, cs: Charset): Either[Throwable, A] = { 19 | e = true 20 | d(b, cs) 21 | } 22 | 23 | def evaluated: Boolean = e 24 | } 25 | 26 | behavior of "body*" 27 | 28 | it should "respond with NotFound when it's required" in { 29 | val b = body[Foo, Text.Plain].apply(Input.get("/")) 30 | b.valueAttempt.unsafeRunSync() shouldBe Left(Error.BodyNotPresent) 31 | } 32 | 33 | it should "respond with None when it's optional" in { 34 | bodyOption[Foo, Text.Plain].apply(Input.get("/")).value.unsafeRunSync() shouldBe None 35 | } 36 | 37 | it should "not match on streaming requests" in { 38 | val req = Request() 39 | req.setChunked(true) 40 | body[Foo, Text.Plain].apply(Input.fromRequest(req)).isMatched shouldBe false 41 | } 42 | 43 | it should "respond with a value when present and required" in 44 | check { f: Foo => 45 | val i = Input.post("/").withBody[Text.Plain](f) 46 | body[Foo, Text.Plain].apply(i).value.unsafeRunSync() === f 47 | } 48 | 49 | it should "respond with Some(value) when it'ss present and optional" in 50 | check { f: Foo => 51 | val i = Input.post("/").withBody[Text.Plain](f) 52 | bodyOption[Foo, Text.Plain].apply(i).value.unsafeRunSync() === Some(f) 53 | } 54 | 55 | it should "treat 0-length bodies as empty" in { 56 | val i = Input.post("/").withHeaders("Content-Length" -> "0") 57 | 58 | bodyOption[Foo, Text.Plain].apply(i).value.unsafeRunSync() shouldBe None 59 | stringBodyOption.apply(i).value.unsafeRunSync() shouldBe None 60 | binaryBodyOption.apply(i).value.unsafeRunSync() shouldBe None 61 | } 62 | 63 | it should "never evaluate until run" in 64 | check { f: Foo => 65 | val i = Input.post("/").withBody[Text.Plain](f) 66 | implicit val ed = new EvalDecode[Foo](Decode[Foo, Text.Plain]) 67 | textBody[Foo].apply(i) 68 | !ed.evaluated 69 | } 70 | 71 | it should "respect Content-Type header and pick corresponding decoder for coproduct" in 72 | check { f: Foo => 73 | val plain = Input.post("/").withBody[Text.Plain](f) 74 | val csv = Input.post("/").withBody[Application.Csv](f) 75 | val endpoint = body[Foo, Text.Plain :+: Application.Csv :+: CNil] 76 | endpoint(plain).value.unsafeRunSync() === f && endpoint(csv).value.unsafeRunSync() === f 77 | } 78 | 79 | it should "resolve into NotParsed(Decode.UMTE) if Content-Type does not match" in { 80 | val i = Input.post("/").withBody[Application.Xml](Buf.Utf8("foo")) 81 | val b = body[Foo, Text.Plain :+: Application.Csv :+: CNil] 82 | inside(b(i).valueAttempt.unsafeRunSync()) { case Left(error) => 83 | error shouldBe a[Error.NotParsed] 84 | error.getCause shouldBe Decode.UnsupportedMediaTypeException 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/BootstrapSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.SyncIO 4 | import com.twitter.finagle.http.{Method, Request, Response, Status} 5 | import io.finch.data.Foo 6 | import io.finch.internal.currentTime 7 | import shapeless.HNil 8 | 9 | import java.time.format.DateTimeFormatter 10 | import java.time.{ZoneOffset, ZonedDateTime} 11 | 12 | class BootstrapSpec extends FinchSpec[SyncIO] { 13 | 14 | behavior of "Bootstrap" 15 | 16 | private val bootstrap = Bootstrap[SyncIO] 17 | 18 | it should "handle both Error and Errors" in 19 | check { e: Either[Error, Errors] => 20 | val exception = e.fold[Exception](identity, identity) 21 | val ee = liftAsync[Unit](SyncIO.raiseError(exception)) 22 | inside(bootstrap.serve[Text.Plain](ee).compile.apply(Request()).unsafeRunSync()) { case (_, Right(rep)) => 23 | rep.status === Status.BadRequest 24 | } 25 | } 26 | 27 | it should "catch custom exceptions in attempt" in { 28 | val exception = new IllegalStateException 29 | val endpoint = liftAsync[Unit](SyncIO.raiseError(exception)) 30 | inside(bootstrap.serve[Text.Plain](endpoint).compile.apply(Request()).unsafeRunSync()) { case (_, Left(e)) => 31 | e shouldBe exception 32 | } 33 | } 34 | 35 | it should "respond 404 if endpoint is not matched" in 36 | check { req: Request => 37 | val s = bootstrap.serve[Text.Plain](Endpoint[SyncIO].empty[Unit]).compile 38 | inside(s(req).unsafeRunSync()) { case (_, Right(rep)) => 39 | rep.status === Status.NotFound 40 | } 41 | } 42 | 43 | it should "respond 405 if method not allowed" in { 44 | val a = get("foo")(Ok("get foo")) 45 | val b = put("foo")(Ok("put foo")) 46 | val c = post("foo")(Ok("post foo")) 47 | 48 | val s = bootstrap.configure(enableMethodNotAllowed = true).serve[Text.Plain](a :+: b).serve[Text.Plain](c).compile 49 | 50 | val aa = Request(Method.Get, "/foo") 51 | val bb = Request(Method.Put, "/foo") 52 | val cc = Request(Method.Post, "/foo") 53 | val dd = Request(Method.Delete, "/foo") 54 | 55 | def response(req: Request): Response = 56 | s(req).unsafeRunSync()._2.toTry.get 57 | 58 | response(Request(Method.Get, "/bar")).status shouldBe Status.NotFound 59 | response(aa).contentString shouldBe "get foo" 60 | response(bb).contentString shouldBe "put foo" 61 | response(cc).contentString shouldBe "post foo" 62 | 63 | val rep = response(dd) 64 | rep.status shouldBe Status.MethodNotAllowed 65 | rep.allow shouldBe Some("POST,GET,PUT") 66 | } 67 | 68 | it should "respond 415 if media type is not supported" in { 69 | val b = body[Foo, Text.Plain] 70 | val s = bootstrap.configure(enableUnsupportedMediaType = true).serve[Text.Plain](b).compile 71 | val i = Input.post("/").withBody[Application.Csv](Foo("bar")) 72 | inside(s(i.request).unsafeRunSync()) { case (_, Right(res)) => 73 | res.status shouldBe Status.UnsupportedMediaType 74 | } 75 | } 76 | 77 | it should "match the request version" in 78 | check { req: Request => 79 | val s = bootstrap.serve[Text.Plain](const(())).compile 80 | inside(s(req).unsafeRunSync()) { case (_, Right(rep)) => 81 | rep.version === req.version 82 | } 83 | } 84 | 85 | it should "include Date header" in { 86 | val formatter = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC) 87 | def parseDate(s: String): Long = ZonedDateTime.parse(s, formatter).toEpochSecond 88 | 89 | check { (req: Request, include: Boolean) => 90 | val s = bootstrap.configure(includeDateHeader = include).serve[Text.Plain](const(())).compile 91 | inside(s(req).unsafeRunSync()) { case (_, Right(rep)) => 92 | val now = parseDate(currentTime()) 93 | (include && (parseDate(rep.date.get) - now).abs <= 1) || (!include && rep.date.isEmpty) 94 | } 95 | } 96 | } 97 | 98 | it should "include Server header" in 99 | check { (req: Request, include: Boolean) => 100 | val s = bootstrap.configure(includeServerHeader = include).serve[Text.Plain](const(())).compile 101 | inside(s(req).unsafeRunSync()) { case (_, Right(rep)) => 102 | (include && rep.server === Some("Finch")) || (!include && rep.server.isEmpty) 103 | } 104 | } 105 | 106 | it should "capture Trace for failures and successes" in 107 | check { req: Request => 108 | val p = req.path.split("/").drop(1) 109 | 110 | val endpoint = p.map(s => path(s)).foldLeft(const(HNil: HNil))((p, e) => p :: e) 111 | 112 | val succ = endpoint.mapAsync(_ => SyncIO.pure("foo")) 113 | val fail = endpoint.mapAsync(_ => SyncIO.raiseError[String](new IllegalStateException)) 114 | 115 | val (successCapture, _) = bootstrap.serve[Text.Plain](succ).compile.apply(req).unsafeRunSync() 116 | val (failureCapture, _) = bootstrap.serve[Text.Plain](fail).compile.apply(req).unsafeRunSync() 117 | 118 | successCapture.toList === p.toList && failureCapture.toList === p.toList 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/DecodeEntityLaws.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Eq 4 | import cats.laws._ 5 | import cats.laws.discipline._ 6 | import org.scalacheck.{Arbitrary, Prop} 7 | import org.typelevel.discipline.Laws 8 | 9 | trait DecodeEntityLaws[A] extends Laws with TestInstances { 10 | 11 | def decode: DecodeEntity[A] 12 | 13 | def roundTrip(a: A): IsEq[Either[Throwable, A]] = 14 | decode(a.toString) <-> Right(a) 15 | 16 | def all(implicit A: Arbitrary[A], eq: Eq[A]): RuleSet = new DefaultRuleSet( 17 | name = "all", 18 | parent = None, 19 | "roundTrip" -> Prop.forAll((a: A) => roundTrip(a)) 20 | ) 21 | } 22 | 23 | object DecodeEntityLaws { 24 | def apply[A: DecodeEntity]: DecodeEntityLaws[A] = new DecodeEntityLaws[A] { 25 | def decode: DecodeEntity[A] = DecodeEntity[A] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/DecodeEntitySpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Id 4 | 5 | import java.util.UUID 6 | 7 | class DecodeEntitySpec extends FinchSpec[Id] { 8 | checkAll("DecodeEntity[String]", DecodeEntityLaws[String].all) 9 | checkAll("DecodeEntity[Int]", DecodeEntityLaws[Int].all) 10 | checkAll("DecodeEntity[Long]", DecodeEntityLaws[Long].all) 11 | checkAll("DecodeEntity[Boolean]", DecodeEntityLaws[Boolean].all) 12 | checkAll("DecodeEntity[Float]", DecodeEntityLaws[Float].all) 13 | checkAll("DecodeEntity[Double]", DecodeEntityLaws[Double].all) 14 | checkAll("DecodeEntity[UUID]", DecodeEntityLaws[UUID].all) 15 | } 16 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/DecodePathLaws.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Eq 4 | import cats.laws._ 5 | import cats.laws.discipline._ 6 | import org.scalacheck.{Arbitrary, Prop} 7 | import org.typelevel.discipline.Laws 8 | 9 | trait DecodePathLaws[A] extends Laws with TestInstances { 10 | 11 | def capture: DecodePath[A] 12 | 13 | def roundTrip(a: A): IsEq[Option[A]] = 14 | capture(a.toString) <-> Some(a) 15 | 16 | def all(implicit A: Arbitrary[A], eq: Eq[A]): RuleSet = new DefaultRuleSet( 17 | name = "all", 18 | parent = None, 19 | "roundTrip" -> Prop.forAll((a: A) => roundTrip(a)) 20 | ) 21 | } 22 | 23 | object DecodePathLaws { 24 | def apply[A: DecodePath]: DecodePathLaws[A] = new DecodePathLaws[A] { 25 | def capture: DecodePath[A] = DecodePath[A] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/DecodePathSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Id 4 | 5 | import java.util.UUID 6 | 7 | class DecodePathSpec extends FinchSpec[Id] { 8 | checkAll("DecodePath[Int]", DecodePathLaws[Int].all) 9 | checkAll("DecodePath[Long]", DecodePathLaws[Long].all) 10 | checkAll("DecodePath[Boolean]", DecodePathLaws[Boolean].all) 11 | checkAll("DecodePath[UUID]", DecodePathLaws[UUID].all) 12 | } 13 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/Dispatchers.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.std.Dispatcher 4 | import cats.effect.unsafe.IORuntime 5 | import cats.effect.{IO, SyncIO} 6 | 7 | import scala.concurrent.Future 8 | 9 | object Dispatchers { 10 | lazy val forIO: Dispatcher[IO] = new Dispatcher[IO] { 11 | implicit val runtime: IORuntime = IORuntime.global 12 | def unsafeToFutureCancelable[A](fa: IO[A]) = fa.unsafeToFutureCancelable() 13 | } 14 | 15 | val forSyncIO: Dispatcher[SyncIO] = new Dispatcher[SyncIO] { 16 | def unsafeToFutureCancelable[A](fa: SyncIO[A]) = 17 | (Future.fromTry(fa.attempt.unsafeRunSync().toTry), () => Future.unit) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/EncodeLaws.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.laws._ 4 | import cats.laws.discipline._ 5 | import com.twitter.io.Buf 6 | import org.scalacheck.{Arbitrary, Prop} 7 | import org.typelevel.discipline.Laws 8 | 9 | import java.nio.charset.Charset 10 | 11 | trait EncodeLaws[A, CT <: String] extends Laws with TestInstances { 12 | 13 | def encode: Encode.Aux[A, CT] 14 | 15 | def roundTrip(a: A, cs: Charset): IsEq[Buf] = 16 | encode(a, cs) <-> Buf.ByteArray.Owned(a.toString.getBytes(cs)) 17 | 18 | def all(implicit A: Arbitrary[A], CS: Arbitrary[Charset]): RuleSet = 19 | new DefaultRuleSet( 20 | name = "all", 21 | parent = None, 22 | "roundTrip" -> Prop.forAll((a: A, cs: Charset) => roundTrip(a, cs)) 23 | ) 24 | } 25 | 26 | object EncodeLaws { 27 | def text[A: Encode.Text]: EncodeLaws[A, Text.Plain] = 28 | new EncodeLaws[A, Text.Plain] { 29 | val encode: Encode.Aux[A, Text.Plain] = implicitly[Encode.Text[A]] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/EncodeSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Id 4 | import com.twitter.io.Buf 5 | 6 | import java.nio.charset.Charset 7 | import java.util.UUID 8 | 9 | class EncodeSpec extends FinchSpec[Id] { 10 | 11 | checkAll("Encode.Text[String]", EncodeLaws.text[String].all) 12 | checkAll("Encode.Text[Int]", EncodeLaws.text[Int].all) 13 | checkAll("Encode.Text[Option[Boolean]]", EncodeLaws.text[Option[Boolean]].all) 14 | checkAll("Encode.Text[List[Long]]", EncodeLaws.text[List[Long]].all) 15 | checkAll("Encode.Text[Either[UUID, Float]]", EncodeLaws.text[Either[UUID, Float]].all) 16 | 17 | it should "round trip Unit" in 18 | check { cs: Charset => 19 | implicitly[Encode[Unit]].apply((), cs) === Buf.Empty 20 | } 21 | 22 | it should "round trip Buf" in 23 | check { (cs: Charset, buf: Buf) => 24 | implicitly[Encode[Buf]].apply(buf, cs) === buf 25 | } 26 | 27 | it should "encode exceptions" in 28 | check { (s: String, cs: Charset) => 29 | val e = new Exception(s) 30 | val text = Encode[Exception, Text.Plain].apply(e, cs) 31 | 32 | text === Buf.ByteArray.Owned(s.getBytes(cs.name)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/EntityEndpointLaws.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.std.Dispatcher 4 | import cats.laws._ 5 | import cats.laws.discipline._ 6 | import cats.{ApplicativeThrow, Eq} 7 | import org.scalacheck.{Arbitrary, Prop} 8 | import org.typelevel.discipline.Laws 9 | 10 | abstract class EntityEndpointLaws[F[_], A] extends Laws with TestInstances { 11 | implicit def F: ApplicativeThrow[F] 12 | 13 | def decoder: DecodeEntity[A] 14 | def dispatcher: Dispatcher[F] 15 | def endpoint: Endpoint[F, Option[A]] 16 | def serialize: A => Input 17 | 18 | def roundTrip(a: A): IsEq[Option[A]] = 19 | dispatcher.unsafeRunSync(endpoint(serialize(a)).valueOption).flatten <-> Some(a) 20 | 21 | def evaluating(implicit A: Arbitrary[A], eq: Eq[A]): RuleSet = 22 | new DefaultRuleSet( 23 | name = "evaluating", 24 | parent = None, 25 | "roundTrip" -> Prop.forAll((a: A) => roundTrip(a)) 26 | ) 27 | } 28 | 29 | object EntityEndpointLaws { 30 | def apply[F[_]: ApplicativeThrow, A: DecodeEntity]( 31 | e: Endpoint[F, Option[A]], 32 | d: Dispatcher[F] 33 | )(f: A => Input): EntityEndpointLaws[F, A] = 34 | new EntityEndpointLaws[F, A] { 35 | val F = ApplicativeThrow[F] 36 | val decoder = DecodeEntity[A] 37 | val dispatcher = d 38 | val endpoint = e 39 | val serialize = f 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/EvaluatingEndpointLaws.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import org.scalacheck.{Arbitrary, Prop} 4 | import org.typelevel.discipline.Laws 5 | 6 | abstract class EvaluatingEndpointLaws[F[_], A] extends Laws with TestInstances { 7 | 8 | def decode: DecodeEntity[A] 9 | def endpoint(d: DecodeEntity[A]): Endpoint[F, A] 10 | 11 | def doNotEvaluateOnMatch(i: Input): Boolean = { 12 | val ed = new EvaluatingEndpointLaws.EvalDecodeEntity[A](decode) 13 | endpoint(ed)(i) 14 | !ed.evaluated 15 | } 16 | 17 | def all(implicit a: Arbitrary[Input]): RuleSet = new DefaultRuleSet( 18 | name = "all", 19 | parent = None, 20 | "doNotEvaluateOnMatch" -> Prop.forAll((i: Input) => doNotEvaluateOnMatch(i)) 21 | ) 22 | } 23 | 24 | object EvaluatingEndpointLaws { 25 | 26 | private class EvalDecodeEntity[A](d: DecodeEntity[A]) extends DecodeEntity[A] { 27 | @volatile private var e = false 28 | def apply(s: String): Either[Throwable, A] = { 29 | e = true 30 | d(s) 31 | } 32 | 33 | def evaluated: Boolean = e 34 | } 35 | 36 | def apply[F[_], A: DecodeEntity](e: DecodeEntity[A] => Endpoint[F, A]): EvaluatingEndpointLaws[F, A] = 37 | new EvaluatingEndpointLaws[F, A] { 38 | val decode: DecodeEntity[A] = DecodeEntity[A] 39 | def endpoint(d: DecodeEntity[A]): Endpoint[F, A] = e(d) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/ExtractPathLaws.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.ApplicativeThrow 4 | import cats.effect.SyncIO 5 | import cats.effect.std.Dispatcher 6 | import io.netty.handler.codec.http.QueryStringEncoder 7 | import org.scalacheck.{Arbitrary, Prop} 8 | import org.typelevel.discipline.Laws 9 | 10 | import scala.reflect.ClassTag 11 | 12 | abstract class ExtractPathLaws[F[_], A] extends Laws with TestInstances { 13 | implicit def F: ApplicativeThrow[F] 14 | 15 | def dispatcher: Dispatcher[F] 16 | def decode: DecodePath[A] 17 | def one: Endpoint[F, A] 18 | def tail: Endpoint[F, List[A]] 19 | 20 | def all(implicit A: Arbitrary[Input]): RuleSet = new DefaultRuleSet( 21 | name = "all", 22 | parent = None, 23 | "extractOne" -> Prop.forAll { input: Input => 24 | val i = input.withRoute(input.route.map(s => new QueryStringEncoder(s).toString)) 25 | val o = one(i) 26 | val v = i.route.headOption.flatMap(s => decode(s)) 27 | dispatcher.unsafeRunSync(o.valueOption) == v && 28 | (v.isEmpty || o.remainder.contains(i.withRoute(i.route.tail))) 29 | }, 30 | "extractTail" -> Prop.forAll { input: Input => 31 | val i = input.withRoute(input.route.map(s => new QueryStringEncoder(s).toString)) 32 | val o = tail(i) 33 | dispatcher.unsafeRunSync(o.valueOption).contains(i.route.flatMap(decode.apply)) && 34 | o.remainder.contains(i.copy(route = Nil)) 35 | } 36 | ) 37 | } 38 | 39 | object ExtractPathLaws { 40 | def apply[A: DecodePath: ClassTag]: ExtractPathLaws[SyncIO, A] = 41 | new ExtractPathLaws[SyncIO, A] { 42 | val F = ApplicativeThrow[SyncIO] 43 | val dispatcher = Dispatchers.forSyncIO 44 | val tail = Endpoint[SyncIO].paths[A] 45 | val one = Endpoint[SyncIO].path[A] 46 | val decode = DecodePath[A] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/HeaderSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.SyncIO 4 | import cats.syntax.all._ 5 | import cats.{Eq, Show} 6 | import io.finch.data.Foo 7 | import org.scalacheck.Arbitrary 8 | 9 | import java.util.UUID 10 | import scala.reflect.ClassTag 11 | 12 | class HeaderSpec extends FinchSpec[SyncIO] { 13 | 14 | behavior of "header*" 15 | 16 | def laws[A: DecodeEntity: Show: ClassTag](k: String) = 17 | EntityEndpointLaws(headerOption[A](k), Dispatchers.forSyncIO)(v => Input.get("/").withHeaders(k -> v.show)) 18 | 19 | checkAll("Header[String]", laws[String]("nickname").evaluating(Arbitrary(genNonEmptyString), Eq[String])) 20 | checkAll("Header[Int]", laws[Int]("level").evaluating) 21 | checkAll("Header[Long]", laws[Long]("gold").evaluating) 22 | checkAll("Header[Boolean]", laws[Boolean]("hard-mode").evaluating) 23 | checkAll("Header[Float]", laws[Float]("multiplier").evaluating) 24 | checkAll("Header[Double]", laws[Double]("score").evaluating) 25 | checkAll("Header[UUID]", laws[UUID]("id").evaluating) 26 | checkAll("Header[Foo]", laws[Foo]("foo").evaluating) 27 | 28 | checkAll( 29 | "EvaluatingHeader[String]", 30 | EvaluatingEndpointLaws[SyncIO, String](implicit de => header("foo")).all 31 | ) 32 | 33 | it should "throw an error if required header is missing" in { 34 | val endpoint = header[UUID]("header") 35 | an[Error.NotPresent] shouldBe thrownBy { 36 | endpoint(Input.get("/index")).value.unsafeRunSync() 37 | } 38 | } 39 | 40 | it should "throw an error if header is malformed" in { 41 | val endpoint = header[UUID]("header") 42 | an[Error.NotParsed] shouldBe thrownBy { 43 | endpoint(Input.get("/index").withHeaders("header" -> "a")).value.unsafeRunSync() 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/InputSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.IORuntime 5 | import com.twitter.finagle.http.Method 6 | import com.twitter.io.{Buf, Pipe, Reader} 7 | import com.twitter.util.{Await, Future} 8 | import io.finch.data.Foo 9 | import io.finch.internal.HttpContent 10 | 11 | import java.nio.charset.Charset 12 | 13 | class InputSpec extends FinchSpec[IO] { 14 | 15 | behavior of "Input" 16 | 17 | it should "properly construct Inputs using factories with params for the different methods" in { 18 | 19 | def validateInput( 20 | input: Input, 21 | method: Method, 22 | segments: Seq[String], 23 | params: Map[String, String] 24 | ): Boolean = 25 | input.request.method === method && 26 | input.request.path === "/" + segments.mkString("/") && 27 | input.request.params === params && 28 | input.route === segments 29 | 30 | check { (ps: Params, p: Path) => 31 | val segments = p.p.split("/").toList.drop(1) 32 | 33 | validateInput(Input.get(p.p, ps.p.toSeq: _*), Method.Get, segments, ps.p) 34 | validateInput(Input.put(p.p, ps.p.toSeq: _*), Method.Put, segments, ps.p) 35 | validateInput(Input.patch(p.p, ps.p.toSeq: _*), Method.Patch, segments, ps.p) 36 | validateInput(Input.delete(p.p, ps.p.toSeq: _*), Method.Delete, segments, ps.p) 37 | } 38 | } 39 | 40 | it should "add fully-buffered content via withBody" in 41 | check { (i: Input, b: Buf) => 42 | i.withBody[Text.Plain](b).request.content === b 43 | } 44 | 45 | it should "add chunked content via withBody" in { 46 | type ListStream[F[_], A] = List[A] 47 | implicit def listToReader[CT <: String]: EncodeStream.Aux[IO, ListStream, Buf, CT] = 48 | new EncodeStream[IO, ListStream, Buf] { 49 | type ContentType = CT 50 | 51 | def apply(s: ListStream[IO, Buf], cs: Charset): IO[Reader[Buf]] = { 52 | val p = new Pipe[Buf] 53 | 54 | def loop(from: List[Buf]): Future[Unit] = from match { 55 | case h :: t => p.write(h).before(loop(t)) 56 | case _ => p.close() 57 | } 58 | 59 | loop(s) 60 | IO.pure(p) 61 | } 62 | } 63 | 64 | check { (i: Input, s: List[Buf]) => 65 | implicit val runtime: IORuntime = IORuntime.global 66 | val out = i.withBody[Application.OctetStream].apply[IO, ListStream, Buf](s).unsafeRunSync().request.reader 67 | s.forall(_ == Await.result(out.read()).get) 68 | } 69 | } 70 | 71 | it should "add content corresponding to a class through withBody[JSON]" in 72 | check { (i: Input, f: Foo, cs: Charset) => 73 | val input = i.withBody[Application.Json](f, cs) 74 | 75 | input.request.content.asString(cs) === s"""{s:"${f.s}"""" && 76 | input.request.contentType === Some(s"application/json;charset=${cs.displayName.toLowerCase}") 77 | } 78 | 79 | it should "add headers through withHeaders" in 80 | check { (i: Input, hs: Headers) => 81 | val hm = i.withHeaders(hs.m.toSeq: _*).request.headerMap 82 | hs.m.forall { case (k, v) => hm.contains(k) && hm(k) === v } 83 | } 84 | 85 | it should "add form elements through withForm" in 86 | check { (i: Input, ps: Params) => 87 | ps.p.isEmpty || { 88 | val input = i.withForm(ps.p.toSeq: _*) 89 | val contentString = input.request.contentString 90 | ps.p.forall { case (k, v) => contentString.contains(s"$k=$v") } && 91 | input.request.contentType === Some("application/x-www-form-urlencoded;charset=utf-8") 92 | } 93 | } 94 | 95 | it should "parse route correctly" in 96 | check { i: Input => 97 | i.route === i.request.path.split("/").toList.drop(1) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/MethodSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Id 4 | import cats.effect.SyncIO 5 | import com.twitter.finagle.http.Response 6 | import org.scalacheck.Arbitrary 7 | 8 | class MethodSpec extends FinchSpec[SyncIO] { 9 | 10 | behavior of "method" 11 | 12 | implicit val arbResponse: Arbitrary[Response] = 13 | Arbitrary(genOutput[String].map(_.toResponse[Id, Text.Plain])) 14 | 15 | it should "map Output value to endpoint" in 16 | checkValue((i: String) => get(zero)(Ok(i))) 17 | 18 | it should "map Response value to endpoint" in 19 | checkValue((i: Response) => get(zero)(i)) 20 | 21 | it should "map F[Output[A]] value to endpoint" in 22 | checkValue((i: String) => get(zero)(SyncIO.pure(Ok(i)))) 23 | 24 | it should "map F[Response] value to endpoint" in 25 | checkValue((i: Response) => get(zero)(SyncIO.pure(Ok(i).toResponse[Id, Text.Plain]))) 26 | 27 | it should "map A => Output function to endpoint" in 28 | checkFunction(get(path[Int]) { i: Int => Ok(i) }) 29 | 30 | it should "map A => Response function to endpoint" in 31 | checkFunction(get(path[Int]) { i: Int => Ok(i).toResponse[Id, Text.Plain] }) 32 | 33 | it should "map A => F[Output[A]] function to endpoint" in 34 | checkFunction(get(path[Int]) { i: Int => SyncIO.pure(i).map(Ok) }) 35 | 36 | it should "map A => F[Response] function to endpoint" in 37 | checkFunction(get(path[Int]) { i: Int => SyncIO.pure(i).map(Ok(_).toResponse[Id, Text.Plain]) }) 38 | 39 | it should "map (A, B) => Output function to endpoint" in 40 | checkFunction2(get(path[Int] :: path[Int])((x: Int, y: Int) => Ok(s"$x$y"))) 41 | 42 | it should "map (A, B) => Response function to endpoint" in 43 | checkFunction2(get(path[Int] :: path[Int])((x: Int, y: Int) => Ok(s"$x$y").toResponse[Id, Text.Plain])) 44 | 45 | it should "map (A, B) => F[Output[String]] function to endpoint" in 46 | checkFunction2(get(path[Int] :: path[Int])((x: Int, y: Int) => SyncIO.pure(Ok(s"$x$y")))) 47 | 48 | it should "map (A, B) => F[Response] function to endpoint" in 49 | checkFunction2(get(path[Int] :: path[Int]) { (x: Int, y: Int) => 50 | SyncIO.pure(Ok(s"$x$y").toResponse[Id, Text.Plain]) 51 | }) 52 | 53 | private def checkValue[A: Arbitrary](f: A => Endpoint[SyncIO, A]): Unit = 54 | forAll { (input: A) => 55 | val e = f(input) 56 | e(Input.get("/")).valueOption.unsafeRunSync() shouldBe Some(input) 57 | } 58 | 59 | private def checkFunction(e: Endpoint[SyncIO, _]): Unit = 60 | forAll { (input: Int) => 61 | e(Input.get(s"/$input")).valueOption.unsafeRunSync() match { 62 | case Some(r: Response) => r.contentString shouldBe input.toString 63 | case Some(a: Int) => a shouldBe input 64 | case _ => () 65 | } 66 | } 67 | 68 | private def checkFunction2(e: Endpoint[SyncIO, _]): Unit = 69 | forAll { (x: Int, y: Int) => 70 | e(Input.get(s"/$x/$y")).valueOption.unsafeRunSync() match { 71 | case Some(r: Response) => r.contentString shouldBe s"$x$y" 72 | case Some(a: String) => a shouldBe s"$x$y" 73 | case _ => () 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/MultipartSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Show 4 | import cats.effect.SyncIO 5 | import com.twitter.finagle.http.exp.Multipart 6 | import com.twitter.finagle.http.{FileElement, RequestBuilder, SimpleElement} 7 | import com.twitter.io.Buf 8 | import io.finch.data.Foo 9 | 10 | import java.util.UUID 11 | import scala.reflect.ClassTag 12 | 13 | class MultipartSpec extends FinchSpec[SyncIO] { 14 | 15 | behavior of "multipart*" 16 | 17 | def withFileUpload(name: String, value: Buf): Input = 18 | Input.fromRequest( 19 | RequestBuilder().url("http://example.com").add(FileElement(name, value, Some("image/gif"), Some("dealwithit.gif"))).buildFormPost(multipart = true) 20 | ) 21 | 22 | def withAttribute[A: Show](first: (String, A), rest: (String, A)*): Input = { 23 | val req = RequestBuilder().url("http://example.com").add(SimpleElement(first._1, Show[A].show(first._2))) 24 | Input.fromRequest( 25 | rest.foldLeft(req)((builder, attr) => builder.add(SimpleElement(attr._1, Show[A].show(attr._2)))).buildFormPost(multipart = true) 26 | ) 27 | } 28 | 29 | def laws[A: DecodeEntity: Show: ClassTag](k: String) = 30 | EntityEndpointLaws(multipartAttributeOption[A](k), Dispatchers.forSyncIO)(v => withAttribute(k -> v)) 31 | 32 | checkAll("Attribute[String]", laws[String]("nickname").evaluating) 33 | checkAll("Attribute[Int]", laws[Int]("level").evaluating) 34 | checkAll("Attribute[Long]", laws[Long]("gold").evaluating) 35 | checkAll("Attribute[Boolean]", laws[Boolean]("hard-mode").evaluating) 36 | checkAll("Attribute[Float]", laws[Float]("multiplier").evaluating) 37 | checkAll("Attribute[Double]", laws[Double]("score").evaluating) 38 | checkAll("Attribute[UUID]", laws[UUID]("id").evaluating) 39 | checkAll("Attribute[Foo]", laws[Foo]("foo").evaluating) 40 | 41 | checkAll( 42 | "EvaluatingAttribute[String]", 43 | EvaluatingEndpointLaws[SyncIO, String](implicit de => multipartAttribute("foo")).all 44 | ) 45 | 46 | it should "file upload (single)" in 47 | check { b: Buf => 48 | val i = withFileUpload("foo", b) 49 | val fu = multipartFileUpload("foo").apply(i).valueOption.unsafeRunSync() 50 | val fuo = multipartFileUploadOption("foo").apply(i).valueOption.unsafeRunSync().flatten 51 | 52 | fu.map(_.asInstanceOf[Multipart.InMemoryFileUpload].content) === Some(b) && 53 | fuo.map(_.asInstanceOf[Multipart.InMemoryFileUpload].content) === Some(b) 54 | } 55 | 56 | it should "fail when attribute is missing" in { 57 | an[Error.NotPresent] should be thrownBy 58 | multipartAttribute("foo").apply(Input.get("/")).value.unsafeRunSync() 59 | } 60 | 61 | it should "return None for when attribute is missing for optional endpoint" in { 62 | multipartAttributeOption("foo").apply(Input.get("/")).valueOption.unsafeRunSync().flatten shouldBe None 63 | } 64 | 65 | it should "fail when attributes are missing" in { 66 | an[Error.NotPresent] should be thrownBy 67 | multipartAttributesNel("foo").apply(Input.get("/")).value.unsafeRunSync() 68 | } 69 | 70 | it should "return empty sequence when attributes are missing for seq endpoint" in 71 | multipartAttributes("foo").apply(Input.get("/")).valueOption.unsafeRunSync() === Some(Seq()) 72 | 73 | it should "fail when attribute is malformed" in { 74 | an[Error.NotParsed] should be thrownBy 75 | multipartAttribute[Int]("foo").apply(withAttribute("foo" -> "bar")).value.unsafeRunSync() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/OutputSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Id 4 | import com.twitter.finagle.http.Status 5 | import com.twitter.io.Buf 6 | 7 | import java.nio.charset.{Charset, StandardCharsets} 8 | import scala.util.{Failure, Success, Try} 9 | 10 | class OutputSpec extends FinchSpec[Id] { 11 | 12 | behavior of "Output" 13 | 14 | it should "propagate status to response" in 15 | check { o: Output[String] => o.toResponse[Id, Text.Plain].status == o.status } 16 | 17 | it should "propagate overridden status to response" in 18 | check { (o: Output[String], s: Status) => 19 | o.withStatus(s).toResponse[Id, Text.Plain].status === s 20 | } 21 | 22 | it should "propagate charset to response" in 23 | check { (o: Output[String], cs: Charset) => 24 | val rep = o.withCharset(cs).toResponse[Id, Text.Plain] 25 | (rep.content.isEmpty && !rep.isChunked) || Some(cs.displayName.toLowerCase) === rep.charset 26 | } 27 | 28 | it should "propagate headers to response" in 29 | check { (o: Output[String], headers: Headers) => 30 | val rep = headers.m.foldLeft(o)((acc, h) => acc.withHeader(h._1 -> h._2)).toResponse[Id, Text.Plain] 31 | headers.m.forall(h => rep.headerMap(h._1) === h._2) 32 | } 33 | 34 | it should "propagate cookies to response" in 35 | check { (o: Output[String], cookies: Cookies) => 36 | val rep = cookies.c.foldLeft(o)((acc, c) => acc.withCookie(c)).toResponse[Id, Text.Plain] 37 | cookies.c.forall(c => rep.cookies(c.name) === c) 38 | } 39 | 40 | it should "set the corresponding status while predefined smart constructors are used" in { 41 | val cause = new Exception 42 | Ok(()).status shouldBe Status.Ok 43 | Created(()).status shouldBe Status.Created 44 | Accepted.status shouldBe Status.Accepted 45 | NoContent.status shouldBe Status.NoContent 46 | BadRequest(cause).status shouldBe Status.BadRequest 47 | Unauthorized(cause).status shouldBe Status.Unauthorized 48 | PaymentRequired(cause).status shouldBe Status.PaymentRequired 49 | Forbidden(cause).status shouldBe Status.Forbidden 50 | NotFound(cause).status shouldBe Status.NotFound 51 | MethodNotAllowed(cause).status shouldBe Status.MethodNotAllowed 52 | NotAcceptable(cause).status shouldBe Status.NotAcceptable 53 | RequestTimeout(cause).status shouldBe Status.RequestTimeout 54 | Conflict(cause).status shouldBe Status.Conflict 55 | Gone(cause).status shouldBe Status.Gone 56 | LengthRequired(cause).status shouldBe Status.LengthRequired 57 | PreconditionFailed(cause).status shouldBe Status.PreconditionFailed 58 | RequestEntityTooLarge(cause).status shouldBe Status.RequestEntityTooLarge 59 | RequestedRangeNotSatisfiable(cause).status shouldBe Status.RequestedRangeNotSatisfiable 60 | EnhanceYourCalm(cause).status shouldBe Status.EnhanceYourCalm 61 | UnprocessableEntity(cause).status shouldBe Status.UnprocessableEntity 62 | TooManyRequests(cause).status shouldBe Status.TooManyRequests 63 | InternalServerError(cause).status shouldBe Status.InternalServerError 64 | NotImplemented(cause).status shouldBe Status.NotImplemented 65 | BadGateway(cause).status shouldBe Status.BadGateway 66 | ServiceUnavailable(cause).status shouldBe Status.ServiceUnavailable 67 | GatewayTimeout(cause).status shouldBe Status.GatewayTimeout 68 | InsufficientStorage(cause).status shouldBe Status.InsufficientStorage 69 | } 70 | 71 | it should "propagate cause to response" in 72 | check { of: Output.Failure => 73 | (of: Output[Unit]).toResponse[Id, Text.Plain].content === 74 | Encode[Exception, Text.Plain].apply(of.cause, of.charset.getOrElse(StandardCharsets.UTF_8)) 75 | } 76 | 77 | it should "propagate empytiness to response" in 78 | check { of: Output.Empty => 79 | (of: Output[Unit]).toResponse[Id, Text.Plain].content === Buf.Empty 80 | } 81 | 82 | it should "propagate payload to response" in 83 | check { op: Output.Payload[String] => 84 | op.toResponse[Id, Text.Plain].content === 85 | Encode[String, Text.Plain].apply(op.value, op.charset.getOrElse(StandardCharsets.UTF_8)) 86 | } 87 | 88 | it should "create an empty endpoint with given status when calling unit" in 89 | check { s: Status => 90 | Output.unit(s).toResponse[Id, Text.Plain].status === s 91 | } 92 | 93 | it should "throw an exception on calling value on an Empty output" in 94 | check { e: Output.Empty => 95 | Try(e.value) match { 96 | case Failure(f) => f.getMessage === "empty output" 97 | case _ => false 98 | } 99 | } 100 | 101 | it should "throw an exception on calling value on a Failure output" in 102 | check { f: Output.Failure => 103 | Try(f.value) match { 104 | case Failure(ex) => ex.getMessage === f.cause.getMessage 105 | case _ => false 106 | } 107 | } 108 | 109 | it should "check equivalences of arbitrary string outputs" in 110 | check { (oa: Output[String], ob: Output[String]) => 111 | Output.outputEq[String].eqv(oa, ob) === (oa == ob) 112 | } 113 | 114 | it should "check equivalences of empty outputs" in 115 | check { (ea: Output.Empty, eb: Output.Empty) => 116 | Output.outputEq[String].eqv(ea, eb) === (ea == eb) 117 | } 118 | 119 | it should "check equivalences of failure outputs" in 120 | check { (fa: Output.Failure, fb: Output.Failure) => 121 | Output.outputEq[String].eqv(fa, fb) === (fa == fb) 122 | } 123 | 124 | it should "traverse arbitrary outputs" in 125 | check { oa: Output[String] => 126 | oa.traverse[Try, String](_ => Success(oa.value)) === Success(oa) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/ParamSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Show 4 | import cats.effect.SyncIO 5 | import cats.syntax.all._ 6 | import io.finch.data.Foo 7 | 8 | import java.util.UUID 9 | import scala.reflect.ClassTag 10 | 11 | class ParamSpec extends FinchSpec[SyncIO] { 12 | 13 | behavior of "param*" 14 | 15 | def laws[A: DecodeEntity: Show: ClassTag](k: String) = 16 | EntityEndpointLaws(paramOption[A](k), Dispatchers.forSyncIO)(v => Input.get("/", k -> v.show)) 17 | 18 | checkAll("Param[String]", laws[String]("nickname").evaluating) 19 | checkAll("Param[Int]", laws[Int]("level").evaluating) 20 | checkAll("Param[Long]", laws[Long]("gold").evaluating) 21 | checkAll("Param[Boolean]", laws[Boolean]("hard-mode").evaluating) 22 | checkAll("Param[Float]", laws[Float]("multiplier").evaluating) 23 | checkAll("Param[Double]", laws[Double]("score").evaluating) 24 | checkAll("Param[UUID]", laws[UUID]("id").evaluating) 25 | checkAll("Param[Foo]", laws[Foo]("foo").evaluating) 26 | 27 | checkAll( 28 | "EvaluatingParam[String]", 29 | EvaluatingEndpointLaws[SyncIO, String](implicit de => param("foo")).all 30 | ) 31 | 32 | it should "throw an error if required param is missing" in { 33 | val endpoint = param[UUID]("testEndpoint") 34 | an[Error.NotPresent] shouldBe thrownBy { 35 | endpoint(Input.get("/index")).value.unsafeRunSync() 36 | } 37 | } 38 | 39 | it should "throw an error if parameter is malformed" in { 40 | val endpoint = param[UUID]("testEndpoint") 41 | an[Error.NotParsed] shouldBe thrownBy { 42 | endpoint(Input.get("/index", "testEndpoint" -> "a")).value.unsafeRunSync() 43 | } 44 | } 45 | 46 | it should "collect errors on Endpoint[Seq[String]] failure" in { 47 | val endpoint = params[UUID]("testEndpoint") 48 | an[Errors] shouldBe thrownBy( 49 | endpoint(Input.get("/index", "testEndpoint" -> "a")).value.unsafeRunSync() 50 | ) 51 | } 52 | 53 | it should "collect errors on Endpoint[NonEmptyList[String]] failure" in { 54 | val endpoint = paramsNel[UUID]("testEndpoint") 55 | an[Errors] shouldBe thrownBy( 56 | endpoint(Input.get("/index", "testEndpoint" -> "a")).value.unsafeRunSync() 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/ServerSentEventSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Show 4 | import com.twitter.io.Buf 5 | import io.finch.internal.HttpContent 6 | import org.scalacheck.Gen.Choose 7 | import org.scalacheck.Prop.propBoolean 8 | import org.scalacheck.{Arbitrary, Gen} 9 | import org.scalatest.flatspec.AnyFlatSpec 10 | import org.scalatest.matchers.should.Matchers 11 | import org.scalatestplus.scalacheck.Checkers 12 | 13 | import java.nio.charset.{Charset, StandardCharsets} 14 | 15 | class ServerSentEventSpec extends AnyFlatSpec with Matchers with Checkers { 16 | 17 | behavior of "ServerSentEvent" 18 | 19 | private[this] def text(s: String, cs: Charset): Buf = Buf.ByteArray.Owned(s.getBytes(cs.name)) 20 | 21 | import ServerSentEvent._ 22 | 23 | def genCharset: Gen[Charset] = Gen.oneOf( 24 | StandardCharsets.ISO_8859_1, 25 | StandardCharsets.US_ASCII, 26 | StandardCharsets.UTF_8, 27 | StandardCharsets.UTF_16, 28 | StandardCharsets.UTF_16BE, 29 | StandardCharsets.UTF_16LE 30 | ) 31 | 32 | implicit def arbitraryCharset: Arbitrary[Charset] = Arbitrary(genCharset) 33 | 34 | def dataOnlySse: Gen[ServerSentEvent[String]] = for { 35 | data <- Gen.alphaStr 36 | } yield ServerSentEvent(data) 37 | 38 | def sseWithId: Gen[ServerSentEvent[String]] = for { 39 | sse <- dataOnlySse 40 | id <- Gen.alphaStr 41 | } yield sse.copy(id = Some(id)) 42 | 43 | def sseWithEventType: Gen[ServerSentEvent[String]] = for { 44 | sse <- dataOnlySse 45 | eventType <- Gen.alphaStr 46 | } yield sse.copy(event = Some(eventType)) 47 | 48 | def sseWithRetry: Gen[ServerSentEvent[String]] = for { 49 | sse <- dataOnlySse 50 | retry <- Choose.chooseLong.choose(-1000, 1000) 51 | } yield sse.copy(retry = Some(retry)) 52 | 53 | val encoder = encodeEventStream[String](Show.fromToString) 54 | 55 | it should "encode the event when only 'data' is present" in { 56 | implicit def arbitraryEvents: Arbitrary[ServerSentEvent[String]] = Arbitrary(dataOnlySse) 57 | 58 | check { (event: ServerSentEvent[String], cs: Charset) => 59 | val encoded = encoder(event, cs) 60 | val expected = Buf(Vector(text("data:", cs), text(event.data, cs), text("\n", cs))) 61 | encoded === expected 62 | } 63 | } 64 | 65 | it should "encode the event when an 'eventType' is present" in { 66 | implicit def arbitraryEvents: Arbitrary[ServerSentEvent[String]] = Arbitrary(sseWithEventType) 67 | 68 | check { (event: ServerSentEvent[String], cs: Charset) => 69 | (event.event.isDefined && event.id.isEmpty && event.retry.isEmpty) ==> { 70 | val encoded = encoder(event, cs) 71 | val actualText = encoded.asString(cs) 72 | val expectedParts = Buf( 73 | Vector( 74 | text("data:", cs), 75 | text(event.data, cs), 76 | text("\n", cs), 77 | text(s"event:${event.event.get}\n", cs) 78 | ) 79 | ) 80 | actualText === expectedParts.asString(cs) 81 | } 82 | } 83 | } 84 | 85 | it should "encode the event when an 'id' is present" in { 86 | implicit def arbitraryEvents: Arbitrary[ServerSentEvent[String]] = Arbitrary(sseWithId) 87 | 88 | check { (event: ServerSentEvent[String], cs: Charset) => 89 | (event.event.isEmpty && event.id.isDefined && event.retry.isEmpty) ==> { 90 | val encoded = encoder(event, cs) 91 | val actualText = encoded.asString(cs) 92 | val expectedParts = Buf( 93 | Vector( 94 | text("data:", cs), 95 | text(event.data, cs), 96 | text("\n", cs), 97 | text(s"id:${event.id.get}\n", cs) 98 | ) 99 | ) 100 | actualText === expectedParts.asString(cs) 101 | } 102 | } 103 | } 104 | 105 | it should "encode the event when a 'retry' is present" in { 106 | implicit def arbitraryEvents: Arbitrary[ServerSentEvent[String]] = Arbitrary(sseWithRetry) 107 | 108 | check { (event: ServerSentEvent[String], cs: Charset) => 109 | (event.event.isEmpty && event.id.isEmpty && event.retry.isDefined) ==> { 110 | val encoded = encoder(event, cs) 111 | val actualText = encoded.asString(cs) 112 | val expectedParts = Buf( 113 | Vector( 114 | text("data:", cs), 115 | text(event.data, cs), 116 | text("\n", cs), 117 | text(s"retry:${event.retry.get}\n", cs) 118 | ) 119 | ) 120 | actualText === expectedParts.asString(cs) 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/StreamingLaws.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.effect.Sync 4 | import cats.effect.std.Dispatcher 5 | import cats.laws._ 6 | import cats.laws.discipline._ 7 | import cats.syntax.all._ 8 | import com.twitter.finagle.http.Request 9 | import com.twitter.io.{Buf, Pipe} 10 | import org.scalacheck.{Arbitrary, Prop} 11 | import org.typelevel.discipline.Laws 12 | 13 | import java.nio.charset.Charset 14 | 15 | abstract class StreamingLaws[S[_[_], _], F[_]] extends Laws with TestInstances { 16 | implicit def LR: LiftReader[S, F] 17 | implicit def F: Sync[F] 18 | 19 | def dispatcher: Dispatcher[F] 20 | def toResponse: ToResponse.Aux[F, S[F, Buf], Text.Plain] 21 | def fromList: List[Buf] => S[F, Buf] 22 | def toList: S[F, Array[Byte]] => F[List[Buf]] 23 | 24 | def roundTrip(a: List[Buf], cs: Charset): IsEq[List[Buf]] = { 25 | val req = Request() 26 | req.setChunked(true) 27 | val rep = dispatcher.unsafeRunSync(toResponse(fromList(a), cs)) 28 | Pipe.copy(rep.reader, req.writer).ensure(req.writer.close()) 29 | dispatcher.unsafeRunSync(Endpoint.binaryBodyStream[F, S].apply(Input.fromRequest(req)).value.flatMap(toList)) <-> a 30 | } 31 | 32 | def onlyChunked: EndpointResult[F, S[F, Array[Byte]]] = 33 | Endpoint.binaryBodyStream[F, S].apply(Input.fromRequest(Request())) 34 | 35 | def all(implicit 36 | arb: Arbitrary[Buf], 37 | CS: Arbitrary[Charset] 38 | ): RuleSet = 39 | new DefaultRuleSet( 40 | name = "all", 41 | parent = None, 42 | "roundTrip" -> Prop.forAll((a: List[Buf], cs: Charset) => roundTrip(a, cs)), 43 | "onlyChunked" -> Prop.=?(EndpointResult.NotMatched[F], onlyChunked) 44 | ) 45 | } 46 | 47 | object StreamingLaws { 48 | 49 | def apply[S[_[_], _], F[_]: Sync]( 50 | d: Dispatcher[F], 51 | from: List[Buf] => S[F, Buf], 52 | to: S[F, Array[Byte]] => F[List[Buf]] 53 | )(implicit lr: LiftReader[S, F], tr: ToResponse.Aux[F, S[F, Buf], Text.Plain]): StreamingLaws[S, F] = 54 | new StreamingLaws[S, F] { 55 | val LR = lr 56 | val F = Sync[F] 57 | val dispatcher = d 58 | val toResponse = tr 59 | val fromList = from 60 | val toList = to 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/TestInstances.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Eq 4 | import cats.syntax.all._ 5 | import com.twitter.io.Buf 6 | 7 | /** Type class instances for non-Finch types. */ 8 | trait TestInstances { 9 | implicit def eqEither[A: Eq]: Eq[Either[Throwable, A]] = { 10 | case (Right(a), Right(b)) => a eqv b 11 | case (Left(x), Left(y)) => x == y 12 | case _ => false 13 | } 14 | 15 | implicit def eqBuf: Eq[Buf] = 16 | Eq.fromUniversalEquals 17 | } 18 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/TraceSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch 2 | 3 | import cats.Id 4 | 5 | class TraceSpec extends FinchSpec[Id] { 6 | 7 | behavior of "Trace" 8 | 9 | it should "round-trip concat/toList" in 10 | check { l: List[String] => 11 | val trace = l.foldLeft(Trace.empty)((t, s) => t.concat(Trace.segment(s))) 12 | trace.toList === l 13 | } 14 | 15 | it should "concat two non-empty segments correctly" in 16 | check { (a: Trace, b: Trace) => 17 | a.concat(b).toList === (a.toList ++ b.toList) 18 | } 19 | 20 | it should "create fromRoute" in 21 | check { l: List[String] => 22 | Trace.fromRoute(l).toList === l 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/data/Foo.scala: -------------------------------------------------------------------------------- 1 | package io.finch.data 2 | 3 | import cats.{Eq, Show} 4 | import com.twitter.io.Buf 5 | import io.finch.internal.HttpContent 6 | import io.finch.{Application, Decode, Encode} 7 | import org.scalacheck.{Arbitrary, Gen} 8 | 9 | case class Foo(s: String) 10 | 11 | object Foo { 12 | implicit val showFoo: Show[Foo] = Show.show(_.s) 13 | 14 | implicit val eqFoo: Eq[Foo] = Eq.fromUniversalEquals 15 | 16 | implicit val decodeTextFoo: Decode.Text[Foo] = 17 | Decode.text((b, cs) => Right(Foo(b.asString(cs)))) 18 | 19 | implicit val decodeCsvFoo: Decode.Aux[Foo, Application.Csv] = 20 | Decode.instance((b, cs) => Right(Foo(b.asString(cs).split("\n").last))) 21 | 22 | implicit val encodeCsvFoo: Encode.Aux[Foo, Application.Csv] = 23 | Encode.instance((a, cs) => 24 | Buf.ByteArray.Owned( 25 | s"""|column 26 | |${a.s}""".stripMargin.getBytes(cs) 27 | ) 28 | ) 29 | 30 | implicit val encodeJsonFoo: Encode.Json[Foo] = 31 | Encode.json((foo, cs) => Buf.ByteArray.Owned(s"""{s:"${foo.s}"""".getBytes(cs))) 32 | 33 | implicit val arbitraryFoo: Arbitrary[Foo] = 34 | Arbitrary(Gen.alphaStr.suchThat(_.nonEmpty).map(Foo.apply)) 35 | } 36 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/internal/HttpContentSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | import cats.Id 4 | import com.twitter.io.Buf 5 | import io.finch.FinchSpec 6 | 7 | import java.nio.charset.Charset 8 | 9 | class HttpContentSpec extends FinchSpec[Id] { 10 | 11 | behavior of "HttpContet" 12 | 13 | it should "asByteArrayWithBeginAndEnd" in 14 | check { b: Buf => 15 | val (array, begin, end) = b.asByteArrayWithBeginAndEnd 16 | Buf.ByteArray.Owned.extract(b) === array.slice(begin, end) 17 | } 18 | 19 | it should "asByteBuffer" in 20 | check { b: Buf => 21 | b.asByteBuffer === Buf.ByteBuffer.Owned.extract(b) 22 | } 23 | 24 | it should "asByteArray" in 25 | check { b: Buf => 26 | b.asByteArray === Buf.ByteArray.Owned.extract(b) 27 | } 28 | 29 | it should "asString" in 30 | check { (b: Buf, cs: Charset) => 31 | b.asString(cs) === new String(Buf.ByteArray.Owned.extract(b), cs) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/internal/HttpMessageSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | import cats.Id 4 | import com.twitter.finagle.http.Request 5 | import io.finch.FinchSpec 6 | 7 | import java.nio.charset.{Charset, StandardCharsets} 8 | 9 | class HttpMessageSpec extends FinchSpec[Id] { 10 | 11 | def slowCharset(req: Request): Charset = req.charset match { 12 | case Some(cs) => Charset.forName(cs) 13 | case None => StandardCharsets.UTF_8 14 | } 15 | 16 | behavior of "HttpMessage" 17 | 18 | it should "charsetOrUtf8" in { 19 | check { cs: Charset => 20 | val req = Request() 21 | req.contentType = "application/json" 22 | req.charset = cs.displayName() 23 | 24 | req.charsetOrUtf8 === slowCharset(req) 25 | } 26 | 27 | check { cs: Charset => 28 | val req = Request() 29 | req.contentType = "application/json; charset=" + cs.displayName() 30 | 31 | req.charsetOrUtf8 === slowCharset(req) 32 | } 33 | 34 | assert(Request().charsetOrUtf8 == StandardCharsets.UTF_8) 35 | } 36 | 37 | it should "mediaTypeOrEmpty" in 38 | check { cs: Option[Charset] => 39 | val req = Request() 40 | req.contentType = "application/json" 41 | cs.foreach(c => req.charset = c.displayName()) 42 | 43 | req.mediaTypeOrEmpty === "application/json" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /core/src/test/scala/io/finch/internal/TooFastStringSpec.scala: -------------------------------------------------------------------------------- 1 | package io.finch.internal 2 | 3 | import com.twitter.util.Try 4 | import org.scalacheck.Gen 5 | import org.scalacheck.Prop.forAll 6 | import org.scalatest.flatspec.AnyFlatSpec 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatestplus.scalacheck.Checkers 9 | 10 | class TooFastStringSpec extends AnyFlatSpec with Matchers with Checkers { 11 | 12 | "TooFastString" should "parse boolean correctly" in { 13 | check { b: Boolean => 14 | b.toString.tooBoolean === Some(b) 15 | } 16 | 17 | "".tooBoolean shouldBe None 18 | "foobarbaz".tooBoolean shouldBe None 19 | } 20 | 21 | it should "parse int correctly" in { 22 | check { i: Int => 23 | i.toString.tooInt === Some(i) 24 | } 25 | 26 | check { 27 | forAll(Gen.numStr) { s => 28 | Try(s.toInt).toOption === s.tooInt 29 | } 30 | } 31 | 32 | "".tooInt shouldBe None 33 | "9999999999".tooInt shouldBe None 34 | "foobarbaz".tooInt shouldBe None 35 | "-9876543210".tooInt shouldBe None 36 | } 37 | 38 | it should "parse long correctly" in { 39 | check { l: Long => 40 | l.toString.tooLong === Some(l) 41 | } 42 | 43 | check { 44 | forAll(Gen.numStr) { s => 45 | Try(s.toLong).toOption === s.tooLong 46 | } 47 | } 48 | 49 | "".tooLong shouldBe None 50 | "99999999999999999999".tooLong shouldBe None 51 | "foobarbazbarbazfoo".tooLong shouldBe None 52 | "-98765432101234567890".tooLong shouldBe None 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /docs/mdoc/contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | --- 4 | Generally, Finch follows a standard [fork and pull][0] model for contributions via GitHub pull requests. Thus, the 5 | _contributing process_ looks as follows: 6 | 7 | 0. [Pick an issue](#pick-an-issue) 8 | 1. [Write code](#write-code) 9 | 2. [Write tests](#write-tests) 10 | 3. [Write docs](#write-docs) 11 | 4. [Submit a PR](#submit-a-pr) 12 | 13 | ## Pick an issue 14 | 15 | * On [Waffle][5], pick any issue from column "Ready" 16 | * On Github, leave a comment on the issue you picked to notify others that the issues is taken 17 | * On [Gitter][6] or Github, ask any question you may have while working on the issue 18 | 19 | ## Write Code 20 | Finch follows the [Effective Scala][1] code style guide. When in doubt, look around the codebase and see how it's done 21 | elsewhere. 22 | 23 | * Code and comments should be formatted to a width no greater than 120 columns 24 | * Files should be exempt of trailing spaces 25 | * Each abstraction with corresponding implementations should live in its own Scala file, i.e `Endpoint.scala` 26 | * Each implicit conversion (if possible) should be defined in the corresponding companion object 27 | 28 | That said, the Scala style checker `sbt scalastyle` should pass on the code. 29 | 30 | ## Write Tests 31 | Finch uses both [ScalaTest][2] and [ScalaCheck][3] with the following settings: 32 | 33 | * Every test should be a `FlatSpec` with `Matchers` and `Checkers` mixed in 34 | * An assertion in tests should be written with `x shouldBe y` 35 | * An assertion in properties (inside `check`) should be written with `===` 36 | * Exceptions should be intercepted with `an [Exception] shouldBe thrownBy(x)` 37 | 38 | ## Write Docs 39 | Write clean and simple docs in the `docs` folder. 40 | 41 | ## Submit a PR 42 | * PR should be submitted from a separate branch (use `git checkout -b "fix-123"`) 43 | * PR should generally contain only one commit (use `git commit --amend` and `git --force push` or [squash][4] existing commits into one) 44 | * PR should not decrease the code coverage more than by 1% 45 | * PR's commit message should use present tense and be capitalized properly (i.e., `Fix #123: Add tests for Endpoint`) 46 | 47 | [0]: https://help.github.com/articles/using-pull-requests/ 48 | [1]: http://twitter.github.io/effectivescala/ 49 | [2]: http://www.scalatest.org/ 50 | [3]: https://www.scalacheck.org/ 51 | [4]: http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html 52 | [5]: https://waffle.io/finagle/finch 53 | [6]: https://gitter.im/finagle/finch 54 | 55 | -------------------------------------------------------------------------------- /docs/mdoc/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | title: "Home" 4 | section: "home" 5 | position: 0 6 | --- 7 | 8 | Finch provides a combinator API over the [Finagle][finagle] HTTP services. An `Endpoint[A]`, main 9 | abstraction for which combinators are defined, represents an HTTP endpoint that takes a request and 10 | returns a value of type `A`. 11 | 12 | ### Look and Feel 13 | 14 | The following example creates an HTTP server (powered by Finagle) that serves the only endpoint 15 | `POST /time`. This endpoint takes a `Locale` instance represented as JSON in request _body_ and 16 | returns a current `Time` for this locale. 17 | 18 | build.sbt: 19 | 20 | ```scala 21 | libraryDependencies ++= Seq( 22 | "com.github.finagle" %% "finch-core" % "0.34.1", 23 | "com.github.finagle" %% "finch-circe" % "0.34.1", 24 | "io.circe" %% "circe-generic" % "0.13.1" 25 | ) 26 | ``` 27 | 28 | Main.scala: 29 | 30 | ```scala mdoc:silent 31 | import com.twitter.finagle.Http 32 | import com.twitter.util.Await 33 | import cats.effect.IO 34 | import io.finch._ 35 | import io.finch.circe._ 36 | import io.finch.catsEffect._ 37 | import io.circe.generic.auto._ 38 | 39 | object Main extends App { 40 | 41 | case class Locale(language: String, country: String) 42 | case class Time(locale: Locale, time: String) 43 | 44 | def currentTime(l: java.util.Locale): String = 45 | java.util.Calendar.getInstance(l).getTime.toString 46 | 47 | val time: Endpoint[IO, Time] = 48 | post("time" :: jsonBody[Locale]) { l: Locale => 49 | Ok(Time(l, currentTime(new java.util.Locale(l.language, l.country)))) 50 | } 51 | 52 | Await.ready(Http.server.serve(":8081", time.toService)) 53 | } 54 | ``` 55 | 56 | ### What People Say? 57 | 58 | [@mandubian](https://twitter.com/mandubian) on 59 | [Twitter](https://twitter.com/mandubian/status/652136674353283072): 60 | 61 | > I think there is clearly room for great improvements using pure FP in Scala for HTTP API & #Finch 62 | > is clearly a good candidate. 63 | 64 | [@tperrigo](https://www.reddit.com/user/tperrigo) on 65 | [Reddit](https://www.reddit.com/r/scala/comments/3kaael/which_framework_to_use_for_development_of_a_rest/cv13vvg): 66 | 67 | > I'm currently working on a project using Finch (with [Circe][circe] to serialize my case classes 68 | > to JSON without any boilerplate code -- in fact, besides the import statements, I don't have to 69 | > do anything to transform my results to JSON) and am extremely impressed. There are still a few 70 | > things in flux with Finch, but I'd recommend giving it a look. 71 | 72 | [@arnarthor](https://github.com/arnarthor) on 73 | [Gitter](https://gitter.im/finagle/finch?at=56159d7476d984a35875c13a): 74 | 75 | > I am currently re-writing a NodeJS service in Finch and the code is so much cleaner and readable 76 | > and about two thirds the amount of lines. Really love this. 77 | 78 | ### Finch Talks 79 | 80 | * [Put Some[Types] on your **HTTP** endpoints][matsuri17] by [@vkostyukov][vkostyukov] in Feb 17 81 | * [Functional Microservices with Finch and Circe][ucon16] by [@davegurnell][davegurnell] in Nov 16 82 | * [Typed Services Using Finch][ylj16] by [@tomjadams][tomjadams] in Apr 2016 83 | * [Finch: Your REST API as a Monad][scalax] by [@vkostyukov][vkostyukov] in Dec 2015 84 | * [On the history of Finch][sfscala-vk] by [@vkostyukov][vkostyukov] in Apr 2015 85 | * [Some possible features for Finch][sfscala-tb] [@travisbrown][travisbrown] in Apr 2015 86 | 87 | 88 | [finagle]: http://twitter.github.io/finagle/ 89 | [circe]: https://github.com/travisbrown/circe 90 | [matsuri17]: http://kostyukov.net/slides/finch-tokyo 91 | [ylj16]: https://www.youtube.com/watch?v=xkZOyY9PG88 92 | [ucon16]: https://skillsmatter.com/skillscasts/9335-high-flying-free-and-easy-functional-microservices-with-finch 93 | [scalax]: https://skillsmatter.com/skillscasts/6876-finch-your-rest-api-as-a-monad 94 | [sfscala-vk]: https://www.youtube.com/watch?v=bbzRTxGDFhs 95 | [sfscala-tb]: https://www.youtube.com/watch?v=noCyZ6B__iE 96 | [vkostyukov]: https://twitter.com/vkostyukov 97 | [travisbrown]: https://twitter.com/travisbrown 98 | [tomjadams]: https://twitter.com/tomjadams 99 | [davegurnell]: https://twitter.com/davegurnell -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/css/override.css: -------------------------------------------------------------------------------- 1 | .technologies { 2 | display: none; 3 | } 4 | 5 | .sidebar-nav > .sidebar-brand a .brand-wrapper { 6 | background-size: 36px 36px !important; 7 | } 8 | 9 | body { 10 | color: #5D5E5D; 11 | } 12 | 13 | #content a { 14 | text-decoration: underline; 15 | } 16 | 17 | #content code { 18 | color: #5D5E5D; 19 | } 20 | 21 | #site-header .navbar-wrapper .brand .icon-wrapper { 22 | width: 36px; 23 | background-size: 100%; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/data/menu.yml: -------------------------------------------------------------------------------- 1 | options: 2 | 3 | - title: User Guide 4 | url: user-guide.html 5 | 6 | - title: Cookbook 7 | url: cookbook.html 8 | 9 | - title: Best Practices 10 | url: best-practices.html 11 | 12 | - title: Contributing 13 | url: contributing.html 14 | 15 | -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/docs/src/main/resources/microsite/img/favicon.png -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/img/jumbotron_pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/docs/src/main/resources/microsite/img/jumbotron_pattern.png -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/img/navbar_brand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/docs/src/main/resources/microsite/img/navbar_brand.png -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/img/navbar_brand2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/docs/src/main/resources/microsite/img/navbar_brand2x.png -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/img/navbar_brand_favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/docs/src/main/resources/microsite/img/navbar_brand_favicon.png -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/img/sidebar_brand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/docs/src/main/resources/microsite/img/sidebar_brand.png -------------------------------------------------------------------------------- /docs/src/main/resources/microsite/img/sidebar_brand2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finagle/finch/1c8b7455fba8100de1499323b1b798e4a06a487f/docs/src/main/resources/microsite/img/sidebar_brand2x.png -------------------------------------------------------------------------------- /docs/src/main/resources/rootdoc.txt: -------------------------------------------------------------------------------- 1 | This is the API documentation for [[https://github.com/finagle/finch finch]] 2 | 3 | Finch is a thin layer of purely functional basic blocks atop of [Finagle][finagle] for 4 | building composable HTTP APIs. Its mission is to provide the developers simple and robust HTTP primitives being as 5 | close as possible to the bare metal Finagle API. 6 | 7 | Finch uses multi-project structure and contains of the following _modules_: 8 | - [[finch-core core]] - the core classes/functions 9 | - [[finch-argonaut argonaut]] - the JSON API support for the [Argonaut][argonaut] library 10 | - [[finch-jackson jackson]] - the JSON API support for the [Jackson][jackson] library 11 | - [[finch-json4s json4s]] - the JSON API support for the [JSON4S][json4s] library 12 | - [[finch-circe circe]] - the JSON API support for the [Circe][circe] library 13 | - [[finch-playjson playjson]] - The JSON API support for the [PlayJson][playjson] library 14 | - [[finch-sprayjson sprayjson]] - The JSON API support for the [SprayJson][sprayjson] library 15 | - [[finch-test test]] - the test support classes/functions 16 | - [[finch-oauth2 oauth2]] - the OAuth2 support backed by the [finagle-oauth2][finagle-oauth2] library 17 | - [[finch-sse see]] - SSE ([Server Sent Events][server-sent-events]) support in Finch 18 | 19 | Please refer to the [[https://finagle.github.io/finch/ documentation]] for a more 20 | detailed introduction to the library. 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/src/main/resources/todo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 71 | 72 | 73 |