├── .github └── workflows │ ├── build.yaml │ └── release.yml ├── .gitignore ├── .mergify.yml ├── .scala-steward.conf ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── core └── src │ ├── main │ ├── scala-2 │ │ └── net │ │ │ └── sigusr │ │ │ ├── impl │ │ │ └── protocol │ │ │ │ └── Direction.scala │ │ │ └── mqtt │ │ │ └── api │ │ │ ├── Errors.scala │ │ │ └── QualityOfService.scala │ ├── scala-3 │ │ └── net │ │ │ └── sigusr │ │ │ ├── impl │ │ │ └── protocol │ │ │ │ └── Direction.scala │ │ │ └── mqtt │ │ │ └── api │ │ │ ├── Errors.scala │ │ │ └── QualityOfService.scala │ └── scala │ │ └── net │ │ └── sigusr │ │ └── mqtt │ │ ├── api │ │ ├── ConnectionState.scala │ │ ├── Session.scala │ │ ├── SessionConfig.scala │ │ └── TransportConfig.scala │ │ └── impl │ │ ├── frames │ │ ├── Builders.scala │ │ ├── ConnectVariableHeader.scala │ │ ├── Frame.scala │ │ ├── Header.scala │ │ ├── RemainingLengthCodec.scala │ │ └── package.scala │ │ └── protocol │ │ ├── AtomicMap.scala │ │ ├── IdGenerator.scala │ │ ├── Protocol.scala │ │ ├── Result.scala │ │ ├── Ticker.scala │ │ ├── Transport.scala │ │ ├── TransportConnector.scala │ │ └── package.scala │ └── test │ └── scala │ └── net │ └── sigusr │ └── mqtt │ ├── SpecUtils.scala │ └── impl │ ├── frames │ ├── CodecSpec.scala │ └── SetDupFlagSpec.scala │ └── protocol │ ├── IdGeneratorSpec.scala │ └── TickerSpec.scala ├── examples └── src │ └── main │ └── scala │ └── net │ └── sigusr │ └── mqtt │ └── examples │ ├── LocalPublisher.scala │ ├── LocalSubscriber.scala │ └── package.scala └── project ├── build.properties └── plugins.sbt /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build and test (All) 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up JDK 12 | uses: olafurpg/setup-scala@v10 13 | - name: Cache SBT 14 | uses: coursier/cache-action@v3 15 | - run: sbt +clean +test 16 | 17 | coverage: 18 | name: Generate coverage report (2.13.10 JVM only) 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up JDK 23 | uses: olafurpg/setup-scala@v10 24 | - name: Cache SBT 25 | uses: coursier/cache-action@v3 26 | - run: sbt ++2.13.10 coverage test coverageReport 27 | - name: Upload code coverage data 28 | run: bash <(curl -s https://codecov.io/bash) 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: [master, main] 5 | tags: ["*"] 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2.3.4 11 | with: 12 | fetch-depth: 0 13 | - uses: olafurpg/setup-scala@v10 14 | - run: sbt ci-release 15 | env: 16 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 17 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 18 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 19 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea*/ 2 | # Created by .gitignore support plugin (hsz.mobi) 3 | ### Scala template 4 | *.class 5 | *.log 6 | 7 | # sbt specific 8 | .cache/ 9 | .history/ 10 | .lib/ 11 | dist/* 12 | target/ 13 | lib_managed/ 14 | src_managed/ 15 | project/boot/ 16 | project/plugins/project/ 17 | 18 | # Scala-IDE specific 19 | .scala_dependencies 20 | .worksheet 21 | 22 | .bloop/ 23 | .metals/ 24 | project/metals.sbt 25 | 26 | .bsp/ 27 | /.tool-versions 28 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | 2 | pull_request_rules: 3 | - name: automatically merge scala-steward's PRs 4 | conditions: 5 | - author=scala-steward 6 | - body~=labels:.*semver-patch.* 7 | - status-success=build 8 | actions: 9 | merge: 10 | method: merge 11 | -------------------------------------------------------------------------------- /.scala-steward.conf: -------------------------------------------------------------------------------- 1 | updates.ignore = [ 2 | { groupId = "co.fs2", artifactId = "fs2-io" }, 3 | { groupId = "com.github.cb372", artifactId = "cats-retry" }, 4 | { groupId = "dev.zio", artifactId = "zio-interop-cats" }, 5 | { groupId = "org.scodec", artifactId = "scodec-stream" }, 6 | { groupId = "org.typelevel" }, 7 | ] -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.9.4 2 | 3 | style = default 4 | 5 | maxColumn = 120 6 | 7 | rewrite.rules = [ 8 | AvoidInfix 9 | RedundantBraces 10 | RedundantParens 11 | AsciiSortImports 12 | PreferCurlyFors 13 | ] 14 | 15 | rewrite.neverInfix.excludeFilters = [until 16 | to 17 | by 18 | eq 19 | ne 20 | "should.*" 21 | "contain.*" 22 | "must.*" 23 | in 24 | be 25 | taggedAs 26 | thrownBy 27 | synchronized 28 | have 29 | when 30 | size 31 | theSameElementsAs] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [akka-mqtt]: https://github.com/fcabestre/Scala-MQTT-client 2 | [akka]: http://akka.io 3 | [apache-licence]: http://github.com/user-signal/fs2-mqtt/blob/master/LICENSE 4 | [cats-effect-retry]: https://cb372.github.io/cats-retry 5 | [cats-effect]: https://github.com/typelevel/cats-effect 6 | [cats]: https://typelevel.org/cats 7 | [ci]: https://github.com/user-signal/fs2-mqtt/actions 8 | [coverage-status-icon]: https://coveralls.io/repos/user-signal/fs2-mqtt/badge.png?branch=master 9 | [coverage-status]: https://coveralls.io/r/user-signal/fs2-mqtt?branch=master 10 | [fs2]: https://fs2.io 11 | [local publisher]: https://github.com/user-signal/fs2-mqtt/blob/master/examples/src/main/scala/net/sigusr/mqtt/examples/LocalPublisher.scala 12 | [local subscriber]: https://github.com/user-signal/fs2-mqtt/blob/master/examples/src/main/scala/net/sigusr/mqtt/examples/LocalSubscriber.scala 13 | [monix]: https://monix.io 14 | [mosquitto]: http://mosquitto.org 15 | [practical-fp]: https://leanpub.com/pfp-scala 16 | [scala-js]: https://www.scala-js.org 17 | [scala]: https://www.scala-lang.org 18 | [scodec-stream]: https://github.com/scodec/scodec-stream 19 | [scodec]: http://scodec.org 20 | [skunk-repo]: https://github.com/tpolecat/skunk 21 | [skunk-talk]: https://youtu.be/NJrgj1vQeAI 22 | [sonatype]: https://oss.sonatype.org/index.html#nexus-search;quick~fs2-mqtt 23 | [tpolecat]: https://twitter.com/tpolecat 24 | [zio]: https://zio.dev 25 | 26 | # A pure functional Scala MQTT client library[![codecov](https://codecov.io/gh/user-signal/fs2-mqtt/branch/master/graph/badge.svg?token=08C9HM2J0L)](https://codecov.io/gh/user-signal/fs2-mqtt) 27 | ## Back then... 28 | 29 | Late 2014, I initiated an [MQTT client library for Scala][akka-mqtt] side project. 30 | My purpose was to learn me some [Akka][akka] while trying to deliver something potentially useful. I quickly 31 | found the excellent [scodec][scodec] library to encode/decode *MQTT* protocol frames, making 32 | this part of the work considerably easier, with a concise and very readable outcome. 33 | 34 | More recently, while getting more and more interest in pure functional programming in *Scala*, in had the chance to see 35 | this amazing talk on [Skunk][skunk-talk] from [@tpolecat][tpolecat]. It's about 36 | building, from the ground up, a data access library for *Postgres* based on [FS2][fs2] and… *scodec*. 37 | 38 | ## Oops!… I did it again. 39 | 40 | I rushed to [Skunk][skunk-repo], which as been inspirational, and took the opportunity of the 41 | lock-down days, around april 2020, to learn a lot about [cats][cats], [cats effect][cats-effect] 42 | and of course *FS2*. I even found the integration between *FS2* and *scodec*, [scodec-stream][scodec-stream], 43 | to be utterly useful. 44 | 45 | With all these tools at hand, and the book [Practical-FP in Scala: A hands-on approach][practical-fp] 46 | on my desk, it has been quite (sic) easy to connect everything together and build this purely functional Scala MQTT 47 | client library. 48 | 49 | ## Current status 50 | 51 | This library is build in the *tagless final* style in order to make it, as much as possible, *IO monad* agnostic for the 52 | client code using it. It's internals are nevertheless mainly build around *FS2*, *cats effect* typeclasses and concurrency 53 | primitives. 54 | 55 | It implements almost all the *MQTT* `3.1.1` protocol, managing (re)connections and in flight messages, and allows interacting 56 | with a [Mosquitto][mosquitto] broker. It does not support *MQTT* `5` and, to tell the truth, this is not even envisioned! 57 | Still, there's work ahead: 58 | * finer grained configuration (e. g. number of in flight messages) 59 | * a proper documentation 60 | * shame on me, far more test coverage /o\ 61 | * performance evaluation 62 | * trying to consider *Scala.js*, 63 | * … 64 | 65 | For examples on how to use it, you can have a look at the [local subscriber][local subscriber] or the [local publisher][local publisher] 66 | code. Both are based on [Cats Effect IO][cats-effect]. 67 | 68 | ## Releases 69 | 70 | Artifacts are available at [Sonatype OSS Repository Hosting service][sonatype], even the ```SNAPSHOTS``` are automatically 71 | built by [GitHub Actions][ci]. To include the Sonatype repositories in your SBT build you should add, 72 | 73 | ```scala 74 | resolvers ++= Seq( 75 | Resolver.sonatypeRepo("releases"), 76 | Resolver.sonatypeRepo("snapshots") 77 | ) 78 | ``` 79 | 80 | In case you want to easily give a try to this library, without the burden of adding resolvers, there is a release synced 81 | to Maven Central. In this case just add, 82 | 83 | ```scala 84 | scalaVersion := "3.3.1" 85 | 86 | libraryDependencies ++= Seq( 87 | "net.sigusr" %% "fs2-mqtt" % "1.0.1" 88 | ) 89 | ``` 90 | 91 | ## Dependencies 92 | 93 | Roughly speaking this library depends on: 94 | * [Scala][scala] of course, but not [Scala.js][scala-js] even thought this should be fairly easy… 95 | * [Cats effect][cats-effect] 96 | * [FS2][fs2] 97 | * [scodec][scodec] and [scodec-stream][scodec-stream] 98 | * [Cats effects retry][cats-effect-retry] to manage connection attempts 99 | 100 | It should work seamlessly with various compatible IO monads: [Cats Effect IO][cats-effect] 101 | of course and [ZIO][zio] as it supports *cats effect* typeclasses. [Monix][monix] used to be supported as well, but 102 | since version `1.0.0` and the migration to [cats effect][cats-effect]`3.x`, this is no more the case. 103 | 104 | ## License 105 | 106 | This work is licenced under an [Apache Version 2.0 license][apache-licence] 107 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import sbt.Keys.* 2 | import sbt.* 3 | 4 | lazy val scala3 = "3.6.4" 5 | lazy val scala213 = "2.13.16" 6 | lazy val scala212 = "2.12.20" 7 | lazy val supportedScalaVersion = Seq(scala3, scala213, scala212) 8 | 9 | lazy val IntegrationTest = config("it").extend(Test) 10 | 11 | val filterConsoleScalacOptions = { options: Seq[String] => 12 | options.filterNot( 13 | Set( 14 | "-Xfatal-warnings", 15 | "-Werror", 16 | "-Wdead-code", 17 | "-Wunused:imports", 18 | "-Ywarn-unused:imports", 19 | "-Ywarn-unused-import", 20 | "-Ywarn-dead-code" 21 | ) 22 | ) 23 | } 24 | 25 | lazy val commonSettings = Seq( 26 | homepage := Some(url("https://github.com/user-signal/fs2-mqtt")), 27 | licenses := List("Apache-2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0")), 28 | developers := List( 29 | Developer( 30 | "fcabestre", 31 | "Frédéric Cabestre", 32 | "frederic.cabestre@sigusr.net", 33 | url("https://github.com/fcabestre") 34 | ) 35 | ), 36 | organization := "net.sigusr", 37 | scalaVersion := scala3, 38 | crossScalaVersions := supportedScalaVersion, 39 | coverageExcludedPackages := "net.sigusr.mqtt.examples", 40 | scalacOptions := Seq( 41 | "-encoding", 42 | "utf-8", 43 | "-feature", 44 | "-language:existentials", 45 | "-language:experimental.macros", 46 | "-language:higherKinds", 47 | "-language:implicitConversions", 48 | "-Xfatal-warnings", 49 | "-unchecked" 50 | ), 51 | scalacOptions ++= (CrossVersion.partialVersion(scalaVersion.value) match { 52 | case Some((2, 12)) => 53 | Seq( 54 | "-Xcheckinit", 55 | "-Xlint:adapted-args", 56 | "-Xlint:constant", 57 | "-Xlint:delayedinit-select", 58 | "-Xlint:doc-detached", 59 | "-Xlint:inaccessible", 60 | "-Xlint:infer-any", 61 | "-Xlint:missing-interpolator", 62 | "-Xlint:nullary-override", 63 | "-Xlint:nullary-unit", 64 | "-Xlint:option-implicit", 65 | "-Xlint:package-object-classes", 66 | "-Xlint:poly-implicit-overload", 67 | "-Xlint:private-shadow", 68 | "-Xlint:stars-align", 69 | "-explaintypes" 70 | ) 71 | case Some((2, 13)) => 72 | Seq( 73 | "-Wdead-code", 74 | "-Wextra-implicit", 75 | "-Wnumeric-widen", 76 | "-Wunused:implicits", 77 | "-Wunused:imports", 78 | "-Wunused:locals", 79 | "-Wunused:params", 80 | "-Wunused:patvars", 81 | "-Wunused:privates", 82 | "-Wvalue-discard", 83 | "-Xcheckinit", 84 | "-Xlint:adapted-args", 85 | "-Xlint:constant", 86 | "-Xlint:delayedinit-select", 87 | "-Xlint:deprecation", 88 | "-Xlint:doc-detached", 89 | "-Xlint:inaccessible", 90 | "-Xlint:infer-any", 91 | "-Xlint:missing-interpolator", 92 | "-Xlint:nullary-unit", 93 | "-Xlint:option-implicit", 94 | "-Xlint:package-object-classes", 95 | "-Xlint:poly-implicit-overload", 96 | "-Xlint:private-shadow", 97 | "-Xlint:stars-align", 98 | "-Xlint:type-parameter-shadow", 99 | "-Ymacro-annotations", 100 | "-explaintypes" 101 | ) 102 | case _ => Seq() 103 | }), 104 | Compile / unmanagedSourceDirectories ++= { 105 | val sourceDir = (Compile / sourceDirectory).value 106 | CrossVersion.partialVersion(scalaVersion.value) match { 107 | case Some((3, _)) => Seq(sourceDir / "scala-3") 108 | case _ => Seq(sourceDir / "scala-2") 109 | } 110 | }, 111 | Compile / console / scalacOptions ~= filterConsoleScalacOptions, 112 | Test / scalacOptions ++= { 113 | val sourceDir = (Compile / sourceDirectory).value 114 | CrossVersion.partialVersion(scalaVersion.value) match { 115 | case Some((3, _)) => Seq() 116 | case _ => Seq("-Yrangepos") 117 | } 118 | }, 119 | Test / console / scalacOptions ~= filterConsoleScalacOptions, 120 | versionScheme := Some("semver-spec") 121 | ) 122 | 123 | lazy val fs2_mqtt = project 124 | .in(file(".")) 125 | .settings( 126 | Seq( 127 | publish / skip := true, 128 | sonatypeProfileName := "net.sigusr" 129 | ) 130 | ) 131 | .aggregate(core, examples) 132 | 133 | lazy val core = project 134 | .in(file("core")) 135 | .configs(IntegrationTest) 136 | .settings( 137 | commonSettings ++ testSettings ++ pgpSettings ++ publishingSettings ++ Seq( 138 | name := "fs2-mqtt", 139 | libraryDependencies ++= Seq( 140 | ("org.specs2" %% "specs2-core" % "4.21.0" % "test").cross(CrossVersion.for3Use2_13), 141 | "org.typelevel" %% "cats-effect-testing-specs2" % "1.4.0" % "test", 142 | "org.typelevel" %% "cats-effect-laws" % "3.4.7" % "test", 143 | "org.typelevel" %% "cats-effect-testkit"% "3.4.7" % "test", 144 | "co.fs2" %% "fs2-io" % "3.10.2", 145 | "co.fs2" %% "fs2-scodec" % "3.12.0", 146 | "org.typelevel" %% "cats-effect" % "3.4.11", 147 | "com.github.cb372" %% "cats-retry" % "3.1.0" 148 | ) ++ { 149 | CrossVersion.partialVersion(scalaVersion.value) match { 150 | case Some((3, _)) => Seq() 151 | case _ => 152 | Seq( 153 | ("com.beachape" %% "enumeratum" % "1.7.6").cross(CrossVersion.for3Use2_13) 154 | ) 155 | } 156 | } 157 | ) 158 | ) 159 | 160 | lazy val examples = project 161 | .in(file("examples")) 162 | .dependsOn(core) 163 | .settings(commonSettings) 164 | 165 | def itFilter(name: String): Boolean = name.startsWith("net.sigusr.mqtt.integration") 166 | def unitFilter(name: String): Boolean = !itFilter(name) 167 | 168 | def testSettings = 169 | Seq( 170 | Test / testOptions := Seq(Tests.Filter(unitFilter)), 171 | IntegrationTest / testOptions := Seq(Tests.Filter(itFilter)) 172 | ) ++ inConfig(IntegrationTest)(Defaults.testTasks) 173 | 174 | import com.jsuereth.sbtpgp.PgpKeys.{gpgCommand, pgpSecretRing, useGpg} 175 | 176 | def pgpSettings = 177 | Seq( 178 | useGpg.withRank(KeyRanks.Invisible) := true, 179 | gpgCommand.withRank(KeyRanks.Invisible) := "/usr/bin/gpg", 180 | pgpSecretRing.withRank(KeyRanks.Invisible) := file("~/.gnupg/secring.gpg") 181 | ) 182 | 183 | val ossSnapshots = "Sonatype OSS Snapshots".at("https://oss.sonatype.org/content/repositories/snapshots/") 184 | val ossStaging = "Sonatype OSS Staging".at("https://oss.sonatype.org/service/local/staging/deploy/maven2/") 185 | 186 | def projectUrl = "https://github.com/user-signal/fs2-mqtt" 187 | def developerId = "fcabestre" 188 | def developerName = "Frédéric Cabestre" 189 | def licenseName = "Apache-2.0" 190 | def licenseDistribution = "repo" 191 | def scmUrl = projectUrl 192 | def scmConnection = "scm:git:" + scmUrl 193 | 194 | def publishingSettings: Seq[Setting[?]] = 195 | Seq( 196 | Test / publishArtifact := false, 197 | pomIncludeRepository := (_ => false) 198 | ) 199 | -------------------------------------------------------------------------------- /core/src/main/scala-2/net/sigusr/impl/protocol/Direction.scala: -------------------------------------------------------------------------------- 1 | package net.sigusr.impl.protocol 2 | 3 | import enumeratum.values.{CharEnum, CharEnumEntry} 4 | 5 | import scala.collection.immutable 6 | 7 | sealed abstract class Direction(val value: Char, val color: String, val active: Boolean) extends CharEnumEntry 8 | object Direction extends CharEnum[Direction] { 9 | case class In(override val active: Boolean) extends Direction('←', Console.YELLOW, active) 10 | case class Out(override val active: Boolean) extends Direction('→', Console.GREEN, active) 11 | 12 | val values: immutable.IndexedSeq[Direction] = findValues 13 | } 14 | -------------------------------------------------------------------------------- /core/src/main/scala-2/net/sigusr/mqtt/api/Errors.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.api 18 | 19 | import cats.Show 20 | import enumeratum.values._ 21 | 22 | import scala.collection.immutable 23 | import scala.util.control.NoStackTrace 24 | 25 | sealed trait Errors extends NoStackTrace 26 | 27 | object Errors { 28 | case class ConnectionFailure(reason: ConnectionFailureReason) extends Errors 29 | 30 | case object ProtocolError extends Errors 31 | } 32 | 33 | sealed abstract class ConnectionFailureReason(val value: Int) extends IntEnumEntry 34 | 35 | object ConnectionFailureReason extends IntEnum[ConnectionFailureReason] { 36 | val values: immutable.IndexedSeq[ConnectionFailureReason] = findValues 37 | 38 | case class TransportError(reason: Throwable) extends ConnectionFailureReason(0) 39 | 40 | case object BadProtocolVersion extends ConnectionFailureReason(1) 41 | 42 | case object IdentifierRejected extends ConnectionFailureReason(2) 43 | 44 | case object ServerUnavailable extends ConnectionFailureReason(3) 45 | 46 | case object BadUserNameOrPassword extends ConnectionFailureReason(4) 47 | 48 | case object NotAuthorized extends ConnectionFailureReason(5) 49 | 50 | def fromOrdinal: Int => ConnectionFailureReason = ConnectionFailureReason.withValue 51 | 52 | implicit val showPerson: Show[ConnectionFailureReason] = Show.show { 53 | case TransportError(reason) => s"Transport error: ${reason.getMessage}" 54 | case BadProtocolVersion => "Bad protocol version" 55 | case IdentifierRejected => "Identifier rejected" 56 | case ServerUnavailable => "Server unavailable" 57 | case BadUserNameOrPassword => "Bad user name or password" 58 | case NotAuthorized => "Not authorized" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /core/src/main/scala-2/net/sigusr/mqtt/api/QualityOfService.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.api 18 | 19 | import cats.Show 20 | import enumeratum.values._ 21 | 22 | import scala.collection.immutable 23 | 24 | sealed abstract class QualityOfService(val value: Int) extends IntEnumEntry 25 | 26 | object QualityOfService extends IntEnum[QualityOfService] { 27 | val values: immutable.IndexedSeq[QualityOfService] = findValues 28 | 29 | object AtMostOnce extends QualityOfService(0) 30 | 31 | object AtLeastOnce extends QualityOfService(1) 32 | 33 | object ExactlyOnce extends QualityOfService(2) 34 | 35 | def fromOrdinal: Int => QualityOfService = QualityOfService.withValue 36 | 37 | implicit val showPerson: Show[QualityOfService] = Show.show { 38 | case AtMostOnce => "at most once" 39 | case AtLeastOnce => "at least once" 40 | case ExactlyOnce => "exactly once" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /core/src/main/scala-3/net/sigusr/impl/protocol/Direction.scala: -------------------------------------------------------------------------------- 1 | package net.sigusr.impl.protocol 2 | 3 | enum Direction(val value: Char, val color: String, val active: Boolean): 4 | case In(override val active: Boolean) extends Direction('←', Console.YELLOW, active) 5 | case Out(override val active: Boolean) extends Direction('→', Console.GREEN, active) 6 | 7 | -------------------------------------------------------------------------------- /core/src/main/scala-3/net/sigusr/mqtt/api/Errors.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.api 18 | 19 | import cats.Show 20 | 21 | import scala.collection.immutable 22 | import scala.util.control.NoStackTrace 23 | 24 | sealed trait Errors extends NoStackTrace 25 | 26 | object Errors { 27 | case class ConnectionFailure(reason: ConnectionFailureReason) extends Errors 28 | 29 | case object ProtocolError extends Errors 30 | } 31 | 32 | enum ConnectionFailureReason(val value: Int): 33 | case TransportError(reason: Throwable) extends ConnectionFailureReason(0) 34 | case BadProtocolVersion extends ConnectionFailureReason(1) 35 | case IdentifierRejected extends ConnectionFailureReason(2) 36 | case ServerUnavailable extends ConnectionFailureReason(3) 37 | case BadUserNameOrPassword extends ConnectionFailureReason(4) 38 | case NotAuthorized extends ConnectionFailureReason(5) 39 | 40 | object ConnectionFailureReason { 41 | implicit val showConnectionFailureReason: Show[ConnectionFailureReason] = Show.show { 42 | case TransportError(reason) => s"Transport error: ${reason.getMessage}" 43 | case BadProtocolVersion => "Bad protocol version" 44 | case IdentifierRejected => "Identifier rejected" 45 | case ServerUnavailable => "Server unavailable" 46 | case BadUserNameOrPassword => "Bad user name or password" 47 | case NotAuthorized => "Not authorized" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /core/src/main/scala-3/net/sigusr/mqtt/api/QualityOfService.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.api 18 | 19 | import cats.Show 20 | 21 | import scala.collection.immutable 22 | 23 | enum QualityOfService(val value: Int): 24 | case AtMostOnce extends QualityOfService(0) 25 | case AtLeastOnce extends QualityOfService(1) 26 | case ExactlyOnce extends QualityOfService(2) 27 | 28 | object QualityOfService { 29 | implicit val showQualityOfService: Show[QualityOfService] = Show.show { 30 | case AtMostOnce => "at most once" 31 | case AtLeastOnce => "at least once" 32 | case ExactlyOnce => "exactly once" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/api/ConnectionState.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.api 18 | 19 | import cats.Show 20 | 21 | import scala.concurrent.duration.FiniteDuration 22 | 23 | sealed trait ConnectionState 24 | object ConnectionState { 25 | 26 | case class Connecting(nextDelay: FiniteDuration, retriesSoFar: Int) extends ConnectionState 27 | 28 | case class Error(error: Errors) extends ConnectionState 29 | 30 | case object Disconnected extends ConnectionState 31 | 32 | case object Connected extends ConnectionState 33 | 34 | case object SessionStarted extends ConnectionState 35 | 36 | implicit val showTransportStatus: Show[ConnectionState] = Show.show { 37 | case Disconnected => "Disconnected" 38 | case Connecting(_, _) => s"Connecting" 39 | case Connected => "Connected" 40 | case SessionStarted => "Session started" 41 | case Error(_) => "Error" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/api/Session.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.api 18 | 19 | import cats.effect.std.Console 20 | import cats.effect.{Resource, Temporal} 21 | import cats.implicits._ 22 | import fs2.Stream 23 | import fs2.concurrent.SignallingRef 24 | import fs2.io.net.Network 25 | import net.sigusr.mqtt.api.Errors.ProtocolError 26 | import net.sigusr.mqtt.api.QualityOfService.AtMostOnce 27 | import net.sigusr.mqtt.impl.frames.Builders._ 28 | import net.sigusr.mqtt.impl.frames._ 29 | import net.sigusr.mqtt.impl.protocol.Result.QoS 30 | import net.sigusr.mqtt.impl.protocol.{IdGenerator, Protocol, Transport} 31 | 32 | sealed case class Message(topic: String, payload: Vector[Byte]) 33 | 34 | trait Session[F[_]] { 35 | 36 | def messages: Stream[F, Message] 37 | 38 | def subscribe(topics: Vector[(String, QualityOfService)]): F[Vector[(String, QualityOfService)]] 39 | 40 | def unsubscribe(topics: Vector[String]): F[Unit] 41 | 42 | def publish( 43 | topic: String, 44 | payload: Vector[Byte], 45 | qos: QualityOfService = AtMostOnce, 46 | retain: Boolean = false 47 | ): F[Unit] 48 | 49 | def state: SignallingRef[F, ConnectionState] 50 | 51 | } 52 | 53 | object Session { 54 | 55 | def apply[F[_]: Temporal: Network: Console]( 56 | transportConfig: TransportConfig[F], 57 | sessionConfig: SessionConfig 58 | ): Resource[F, Session[F]] = Resource(fromTransport(transportConfig, sessionConfig)) 59 | 60 | private def fromTransport[F[_]: Temporal: Network : Console]( 61 | transportConfig: TransportConfig[F], 62 | sessionConfig: SessionConfig 63 | ): F[(Session[F], F[Unit])] = 64 | for { 65 | 66 | ids <- IdGenerator[F]() 67 | protocol <- Protocol(sessionConfig, Transport[F](transportConfig)) 68 | 69 | } yield ( 70 | new Session[F] { 71 | 72 | override val messages: Stream[F, Message] = protocol.messages 73 | 74 | override def subscribe(topics: Vector[(String, QualityOfService)]): F[Vector[(String, QualityOfService)]] = 75 | for { 76 | messageId <- ids.next 77 | v <- protocol.sendReceive(subscribeFrame(messageId, topics), messageId) 78 | } yield v match { 79 | case QoS(t) => topics.zip(t).map(p => (p._1._1, QualityOfService.fromOrdinal(p._2))) 80 | case _ => throw ProtocolError 81 | } 82 | 83 | override def unsubscribe(topics: Vector[String]): F[Unit] = 84 | for { 85 | messageId <- ids.next 86 | _ <- protocol.sendReceive(unsubscribeFrame(messageId, topics), messageId) 87 | } yield () 88 | 89 | override def publish(topic: String, payload: Vector[Byte], qos: QualityOfService, retain: Boolean): F[Unit] = 90 | qos match { 91 | case QualityOfService.AtMostOnce => 92 | protocol.send(publishFrame(topic, None, payload, qos, retain)) 93 | case QualityOfService.AtLeastOnce | QualityOfService.ExactlyOnce => 94 | for { 95 | messageId <- ids.next 96 | _ <- protocol.sendReceive(publishFrame(topic, Some(messageId), payload, qos, retain), messageId) 97 | } yield () 98 | } 99 | 100 | override val state: SignallingRef[F, ConnectionState] = protocol.state 101 | }, 102 | disconnect(protocol) 103 | ) 104 | 105 | private def disconnect[F[_]](protocol: Protocol[F]): F[Unit] = { 106 | val disconnectMessage = DisconnectFrame(Header()) 107 | protocol.send(disconnectMessage) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/api/SessionConfig.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.api 18 | 19 | import net.sigusr.mqtt.impl.protocol.DEFAULT_KEEP_ALIVE 20 | 21 | sealed case class Will(retain: Boolean, qos: QualityOfService, topic: String, message: String) 22 | 23 | sealed case class SessionConfig( 24 | clientId: String, 25 | keepAlive: Int = DEFAULT_KEEP_ALIVE, 26 | cleanSession: Boolean = true, 27 | will: Option[Will] = None, 28 | user: Option[String] = None, 29 | password: Option[String] = None 30 | ) 31 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/api/TransportConfig.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.api 18 | 19 | import cats.Applicative 20 | import cats.effect.Async 21 | import com.comcast.ip4s.{Host, Port} 22 | import fs2.io.net.tls.{TLSContext, TLSParameters} 23 | import net.sigusr.mqtt.api.PredefinedRetryPolicy.{ConstantDelay, ExponentialBackoff, FibonacciBackoff, FullJitter} 24 | import net.sigusr.mqtt.api.RetryConfig.Predefined 25 | import retry.{RetryPolicies, RetryPolicy} 26 | 27 | import scala.concurrent.duration._ 28 | 29 | sealed trait PredefinedRetryPolicy 30 | object PredefinedRetryPolicy { 31 | case object ConstantDelay extends PredefinedRetryPolicy 32 | case object ExponentialBackoff extends PredefinedRetryPolicy 33 | case object FibonacciBackoff extends PredefinedRetryPolicy 34 | case object FullJitter extends PredefinedRetryPolicy 35 | } 36 | 37 | sealed trait RetryConfig[F[_]] 38 | object RetryConfig { 39 | def policyOf[F[_]: Applicative](retryConfig: RetryConfig[F]): RetryPolicy[F] = 40 | retryConfig match { 41 | case Predefined(policy, maxRetries, baseDelay) => 42 | RetryPolicies 43 | .limitRetries(maxRetries) 44 | .join(basePolicy(policy, baseDelay)) 45 | case Custom(policy) => policy 46 | } 47 | 48 | private def basePolicy[F[_]: Applicative]( 49 | predefinedRetryPolicy: PredefinedRetryPolicy, 50 | baseDelay: FiniteDuration 51 | ): RetryPolicy[F] = 52 | predefinedRetryPolicy match { 53 | case ConstantDelay => RetryPolicies.constantDelay(baseDelay) 54 | case ExponentialBackoff => RetryPolicies.exponentialBackoff(baseDelay) 55 | case FibonacciBackoff => RetryPolicies.fibonacciBackoff(baseDelay) 56 | case FullJitter => RetryPolicies.fullJitter(baseDelay) 57 | } 58 | 59 | case class Predefined[F[_]]( 60 | policy: PredefinedRetryPolicy = FibonacciBackoff, 61 | maxRetries: Int = 5, 62 | baseDelay: FiniteDuration = 2.seconds 63 | ) extends RetryConfig[F] 64 | 65 | case class Custom[F[_]](policy: RetryPolicy[F]) extends RetryConfig[F] 66 | } 67 | 68 | sealed trait TLSContextKind 69 | object TLSContextKind { 70 | case object System extends TLSContextKind 71 | case object Insecure extends TLSContextKind 72 | } 73 | 74 | sealed case class TLSConfig[F[_]: Async]( 75 | private val tlsContextKind: TLSContextKind, 76 | tlsParameters: TLSParameters 77 | ) { 78 | def contextOf: F[TLSContext[F]] = 79 | tlsContextKind match { 80 | case TLSContextKind.System => TLSContext.Builder.forAsync[F].system 81 | case TLSContextKind.Insecure => TLSContext.Builder.forAsync[F].insecure 82 | } 83 | } 84 | 85 | object TLSConfig { 86 | def apply[F[_]: Async](tlsContextKind: TLSContextKind, tlsParameters: TLSParameters = TLSParameters()) = 87 | new TLSConfig[F](tlsContextKind, tlsParameters) 88 | } 89 | 90 | sealed case class TransportConfig[F[_]]( 91 | host: Host, 92 | port: Port, 93 | tlsConfig: Option[TLSConfig[F]] = None, 94 | readTimeout: Option[FiniteDuration] = None, 95 | writeTimeout: Option[FiniteDuration] = None, 96 | retryConfig: RetryConfig[F] = Predefined[F](), 97 | numReadBytes: Int = 4096, 98 | traceMessages: Boolean = false 99 | ) 100 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/frames/Builders.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.frames 18 | 19 | import net.sigusr.mqtt.api.QualityOfService.{AtLeastOnce, AtMostOnce} 20 | import net.sigusr.mqtt.api.{QualityOfService, SessionConfig} 21 | import scodec.bits.ByteVector 22 | 23 | object Builders { 24 | 25 | def subscribeFrame(messageId: Int, topics: Vector[(String, QualityOfService)]): SubscribeFrame = 26 | SubscribeFrame( 27 | Header(qos = AtLeastOnce.value), 28 | messageId, 29 | topics.map((v: (String, QualityOfService)) => (v._1, v._2.value)) 30 | ) 31 | 32 | def unsubscribeFrame(messageId: Int, topics: Vector[String]): UnsubscribeFrame = 33 | UnsubscribeFrame(Header(qos = AtLeastOnce.value), messageId, topics) 34 | 35 | def connectFrame(config: SessionConfig): ConnectFrame = { 36 | val header = Header(qos = AtMostOnce.value) 37 | val retain = config.will.fold(false)(_.retain) 38 | val qos = config.will.fold(AtMostOnce.value)(_.qos.value) 39 | val topic = config.will.map(_.topic) 40 | val message = config.will.map(_.message) 41 | val variableHeader = 42 | ConnectVariableHeader( 43 | config.user.isDefined, 44 | config.password.isDefined, 45 | willRetain = retain, 46 | qos, 47 | willFlag = config.will.isDefined, 48 | config.cleanSession, 49 | config.keepAlive 50 | ) 51 | ConnectFrame(header, variableHeader, config.clientId, topic, message, config.user, config.password) 52 | } 53 | 54 | def publishFrame( 55 | topic: String, 56 | messageId: Option[Int], 57 | payload: Vector[Byte], 58 | qos: QualityOfService, 59 | retain: Boolean 60 | ): PublishFrame = { 61 | val header = Header(dup = false, qos.value, retain = retain) 62 | PublishFrame(header, topic, messageId, ByteVector(payload)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/frames/ConnectVariableHeader.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.frames 18 | 19 | import scodec.Codec 20 | import scodec.bits.{BitVector, _} 21 | import scodec.codecs._ 22 | 23 | case class ConnectVariableHeader( 24 | userNameFlag: Boolean, 25 | passwordFlag: Boolean, 26 | willRetain: Boolean, 27 | willQoS: Int, 28 | willFlag: Boolean, 29 | cleanSession: Boolean, 30 | keepAliveTimer: Int 31 | ) {} 32 | 33 | object ConnectVariableHeader { 34 | 35 | /** This is the, once for all encoded, protocol name [MQIsdp] and protocol version [3] 36 | */ 37 | val connectVariableHeaderFixedBytes: BitVector = BitVector(hex"00064D514973647003") 38 | 39 | implicit val connectVariableHeaderCodec: Codec[ConnectVariableHeader] = 40 | (constant(connectVariableHeaderFixedBytes) ~> 41 | bool :: 42 | bool :: 43 | bool :: 44 | qosCodec :: 45 | bool :: 46 | bool :: 47 | ignore(1) ~> 48 | keepAliveCodec).as[ConnectVariableHeader] 49 | } 50 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/frames/Frame.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.frames 18 | 19 | import net.sigusr.mqtt.impl.frames.ConnectVariableHeader._ 20 | import net.sigusr.mqtt.impl.frames.Header._ 21 | import scodec.Codec 22 | import scodec.bits.ByteVector 23 | import scodec.codecs._ 24 | 25 | sealed trait Frame { 26 | def header: Header 27 | } 28 | 29 | case class ConnectFrame( 30 | header: Header, 31 | variableHeader: ConnectVariableHeader, 32 | clientId: String, 33 | topic: Option[String], 34 | message: Option[String], 35 | user: Option[String], 36 | password: Option[String] 37 | ) extends Frame 38 | 39 | case class ConnackFrame(header: Header, returnCode: Int) extends Frame 40 | case class PublishFrame(header: Header, topic: String, messageIdentifier: Option[Int], payload: ByteVector) 41 | extends Frame 42 | case class PubackFrame(header: Header, messageIdentifier: Int) extends Frame 43 | case class PubrecFrame(header: Header, messageIdentifier: Int) extends Frame 44 | case class PubrelFrame(header: Header, messageIdentifier: Int) extends Frame 45 | case class PubcompFrame(header: Header, messageIdentifier: Int) extends Frame 46 | case class SubscribeFrame(header: Header, messageIdentifier: Int, topics: Vector[(String, Int)]) extends Frame 47 | case class SubackFrame(header: Header, messageIdentifier: Int, topics: Vector[Int]) extends Frame 48 | case class UnsubscribeFrame(header: Header, messageIdentifier: Int, topics: Vector[String]) extends Frame 49 | case class UnsubackFrame(header: Header, messageIdentifier: Int) extends Frame 50 | case class PingReqFrame(header: Header) extends Frame 51 | case class PingRespFrame(header: Header) extends Frame 52 | case class DisconnectFrame(header: Header) extends Frame 53 | 54 | object Frame { 55 | implicit val codec: Codec[Frame] = 56 | discriminated[Frame] 57 | .by(uint4) 58 | .typecase(1, ConnectFrame.codec) 59 | .typecase(2, ConnackFrame.codec) 60 | .typecase(3, PublishFrame.codec) 61 | .typecase(4, PubackFrame.codec) 62 | .typecase(5, PubrecFrame.codec) 63 | .typecase(6, PubrelFrame.codec) 64 | .typecase(7, PubcompFrame.codec) 65 | .typecase(8, SubscribeFrame.codec) 66 | .typecase(9, SubackFrame.codec) 67 | .typecase(10, UnsubscribeFrame.codec) 68 | .typecase(11, UnsubackFrame.codec) 69 | .typecase(12, PingReqFrame.codec) 70 | .typecase(13, PingRespFrame.codec) 71 | .typecase(14, DisconnectFrame.codec) 72 | } 73 | 74 | object ConnectFrame { 75 | implicit val codec: Codec[ConnectFrame] = (headerCodec :: variableSizeBytes( 76 | remainingLengthCodec, 77 | connectVariableHeaderCodec.flatPrepend { (hdr: ConnectVariableHeader) => 78 | stringCodec :: 79 | conditional(hdr.willFlag, stringCodec) :: 80 | conditional(hdr.willFlag, stringCodec) :: 81 | conditional(hdr.userNameFlag, stringCodec) :: 82 | conditional(hdr.passwordFlag, stringCodec) 83 | } 84 | )).as[ConnectFrame] 85 | } 86 | 87 | object ConnackFrame { 88 | implicit val codec: Codec[ConnackFrame] = 89 | (headerCodec :: variableSizeBytes(remainingLengthCodec, bytePaddingCodec ~> returnCodeCodec)).as[ConnackFrame] 90 | } 91 | 92 | object PublishFrame { 93 | implicit val codec: Codec[PublishFrame] = headerCodec 94 | .flatPrepend { (hdr: Header) => 95 | variableSizeBytes(remainingLengthCodec, stringCodec :: conditional(hdr.qos != 0, messageIdCodec) :: bytes) 96 | } 97 | .as[PublishFrame] 98 | } 99 | 100 | object PubackFrame { 101 | implicit val codec: Codec[PubackFrame] = 102 | (headerCodec :: variableSizeBytes(remainingLengthCodec, messageIdCodec)).as[PubackFrame] 103 | } 104 | 105 | object PubrecFrame { 106 | implicit val codec: Codec[PubrecFrame] = 107 | (headerCodec :: variableSizeBytes(remainingLengthCodec, messageIdCodec)).as[PubrecFrame] 108 | } 109 | 110 | object PubrelFrame { 111 | implicit val codec: Codec[PubrelFrame] = 112 | (headerCodec :: variableSizeBytes(remainingLengthCodec, messageIdCodec)).as[PubrelFrame] 113 | } 114 | 115 | object PubcompFrame { 116 | implicit val codec: Codec[PubcompFrame] = 117 | (headerCodec :: variableSizeBytes(remainingLengthCodec, messageIdCodec)).as[PubcompFrame] 118 | } 119 | 120 | object SubscribeFrame { 121 | val topicCodec: Codec[(String, Int)] = (stringCodec :: ignore(6) :: uint2).dropUnits.as[(String, Int)] 122 | implicit val topicsCodec: Codec[Vector[(String, Int)]] = vector(topicCodec) 123 | implicit val codec: Codec[SubscribeFrame] = 124 | (headerCodec :: variableSizeBytes(remainingLengthCodec, messageIdCodec :: topicsCodec)).as[SubscribeFrame] 125 | } 126 | 127 | object SubackFrame { 128 | implicit val qosCodec: Codec[Vector[Int]] = vector(ignore(6).dropLeft(uint2)) 129 | implicit val codec: Codec[SubackFrame] = 130 | (headerCodec :: variableSizeBytes(remainingLengthCodec, messageIdCodec :: qosCodec)).as[SubackFrame] 131 | } 132 | 133 | object UnsubscribeFrame { 134 | implicit val codec: Codec[UnsubscribeFrame] = 135 | (headerCodec :: variableSizeBytes(remainingLengthCodec, messageIdCodec :: vector(stringCodec))).as[UnsubscribeFrame] 136 | } 137 | 138 | object UnsubackFrame { 139 | implicit val codec: Codec[UnsubackFrame] = 140 | (headerCodec :: variableSizeBytes(remainingLengthCodec, messageIdCodec)).as[UnsubackFrame] 141 | } 142 | 143 | object PingReqFrame { 144 | implicit val codec: Codec[PingReqFrame] = (headerCodec :: bytePaddingCodec).dropUnits.as[PingReqFrame] 145 | } 146 | 147 | object PingRespFrame { 148 | implicit val codec: Codec[PingRespFrame] = (headerCodec :: bytePaddingCodec).dropUnits.as[PingRespFrame] 149 | } 150 | 151 | object DisconnectFrame { 152 | implicit val codec: Codec[DisconnectFrame] = (headerCodec :: bytePaddingCodec).dropUnits.as[DisconnectFrame] 153 | } 154 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/frames/Header.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.frames 18 | 19 | import net.sigusr.mqtt.api.QualityOfService.AtMostOnce 20 | import scodec.Codec 21 | import scodec.codecs._ 22 | 23 | case class Header(dup: Boolean = false, qos: Int = AtMostOnce.value, retain: Boolean = false) 24 | 25 | object Header { 26 | implicit val headerCodec: Codec[Header] = (bool :: qosCodec :: bool).as[Header] 27 | } 28 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/frames/RemainingLengthCodec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.frames 18 | 19 | import scodec.Attempt._ 20 | import scodec._ 21 | import scodec.bits.{BitVector, _} 22 | import scodec.codecs._ 23 | 24 | final class RemainingLengthCodec extends Codec[Int] { 25 | 26 | val MinValue = 0 27 | val MaxValue = 268435455 28 | 29 | def sizeBound: SizeBound = SizeBound.bounded(8, 32) 30 | 31 | def decode(bits: BitVector): Attempt[DecodeResult[Int]] = { 32 | @annotation.tailrec 33 | def decodeAux(step: Attempt[DecodeResult[Int]], factor: Int, depth: Int, value: Int): Attempt[DecodeResult[Int]] = 34 | if (depth == 4) failure(Err("The remaining length must be 4 bytes long at most")) 35 | else 36 | step match { 37 | case f: Failure => f 38 | case Successful(d) => 39 | if ((d.value & 128) == 0) successful(DecodeResult(value + (d.value & 127) * factor, d.remainder)) 40 | else decodeAux(uint8.decode(d.remainder), factor * 128, depth + 1, value + (d.value & 127) * factor) 41 | } 42 | decodeAux(uint8.decode(bits), 1, 0, 0) 43 | } 44 | 45 | def encode(value: Int): Attempt[BitVector] = { 46 | @annotation.tailrec 47 | def encodeAux(value: Int, digit: Int, bytes: ByteVector): ByteVector = 48 | if (value == 0) bytes :+ digit.asInstanceOf[Byte] 49 | else encodeAux(value / 128, value % 128, bytes :+ (digit | 0x80).asInstanceOf[Byte]) 50 | if (value < MinValue || value > MaxValue) 51 | failure(Err(s"The remaining length must be in the range [$MinValue..$MaxValue], $value is not valid")) 52 | else successful(BitVector(encodeAux(value / 128, value % 128, ByteVector.empty))) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/frames/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl 18 | 19 | import scodec.Codec 20 | import scodec.bits._ 21 | import scodec.codecs._ 22 | 23 | package object frames { 24 | 25 | val qosCodec: Codec[Int] = uint2 26 | val returnCodeCodec: Codec[Int] = uint8 27 | val messageIdCodec: Codec[Int] = uint16 28 | val keepAliveCodec: Codec[Int] = uint16 29 | 30 | val remainingLengthCodec = new RemainingLengthCodec 31 | val stringCodec: Codec[String] = variableSizeBytes(uint16, utf8) 32 | val bytePaddingCodec: Codec[Unit] = constant(bin"00000000") 33 | val setDupFlag: Frame => Frame = { 34 | case f0: PublishFrame => f0.copy(f0.header.copy(dup = true)) 35 | case f1: PubrelFrame => f1.copy(f1.header.copy(dup = true)) 36 | case f2: SubscribeFrame => f2.copy(f2.header.copy(dup = true)) 37 | case f3: UnsubscribeFrame => f3.copy(f3.header.copy(dup = true)) 38 | case f: Frame => f 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/protocol/AtomicMap.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.protocol 18 | 19 | import cats.effect.Concurrent 20 | import cats.effect.Ref 21 | import cats.implicits._ 22 | 23 | import scala.collection.immutable.TreeMap 24 | 25 | trait AtomicMap[F[_], K, V] { 26 | 27 | def update(key: K, result: V): F[Unit] 28 | 29 | def remove(key: K): F[Option[V]] 30 | 31 | def removeAll(): F[List[V]] 32 | } 33 | 34 | object AtomicMap { 35 | 36 | def apply[F[_]: Concurrent, K: Ordering, V]: F[AtomicMap[F, K, V]] = 37 | for { 38 | 39 | mm <- Ref.of[F, TreeMap[K, V]](TreeMap.empty[K, V]) 40 | 41 | } yield new AtomicMap[F, K, V]() { 42 | 43 | override def update(key: K, result: V): F[Unit] = mm.update(m => m.updated(key, result)) 44 | 45 | override def remove(key: K): F[Option[V]] = mm.modify(m => (m - key, m.get(key))) 46 | 47 | override def removeAll(): F[List[V]] = mm.modify(m => (TreeMap.empty[K, V], m.toSeq.map(p => p._2).toList)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/protocol/IdGenerator.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.protocol 18 | 19 | import cats.effect.Concurrent 20 | import cats.effect.Ref 21 | import cats.syntax.all._ 22 | 23 | trait IdGenerator[F[_]] { 24 | def next: F[Int] 25 | } 26 | 27 | object IdGenerator { 28 | 29 | def apply[F[_] : Concurrent](): F[IdGenerator[F]] = 30 | for { 31 | r <- Ref[F].of(0) 32 | } yield new IdGenerator[F] { 33 | override def next: F[Int] = 34 | r.modify(i => ((i + 1) % 65535, i + 1)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/protocol/Protocol.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.protocol 18 | 19 | import cats.effect.std.Queue 20 | import cats.effect.{Concurrent, Deferred, Ref, Temporal} 21 | import cats.implicits._ 22 | import fs2.concurrent.SignallingRef 23 | import fs2.{Pipe, Pull, Stream} 24 | import net.sigusr.mqtt.api.ConnectionState.{Connected, Disconnected, Error, SessionStarted} 25 | import net.sigusr.mqtt.api.Errors.{ConnectionFailure, ProtocolError} 26 | import net.sigusr.mqtt.api.QualityOfService.{AtLeastOnce, AtMostOnce, ExactlyOnce} 27 | import net.sigusr.mqtt.api._ 28 | import net.sigusr.mqtt.impl.frames.Builders.connectFrame 29 | import net.sigusr.mqtt.impl.frames._ 30 | import net.sigusr.mqtt.impl.protocol.Result.{Empty, QoS} 31 | import scodec.bits.ByteVector 32 | 33 | trait Protocol[F[_]] { 34 | 35 | def send: Frame => F[Unit] 36 | 37 | def sendReceive(frame: Frame, messageId: Int): F[Result] 38 | 39 | def cancel: F[Unit] 40 | 41 | def messages: Stream[F, Message] 42 | 43 | def state: SignallingRef[F, ConnectionState] 44 | 45 | } 46 | 47 | object Protocol { 48 | 49 | def apply[F[_]: Temporal]( 50 | sessionConfig: SessionConfig, 51 | transport: TransportConnector[F] => F[Transport[F]] 52 | ): F[Protocol[F]] = { 53 | 54 | def inboundMessagesInterpreter( 55 | messageQueue: Queue[F, Message], 56 | frameQueue: Queue[F, Frame], 57 | inFlightOutBound: AtomicMap[F, Int, Frame], 58 | pendingResults: AtomicMap[F, Int, Deferred[F, Result]], 59 | stateSignal: SignallingRef[F, ConnectionState], 60 | closeSignal: SignallingRef[F, Boolean], 61 | pingAcknowledged: Ref[F, Boolean] 62 | ): Pipe[F, Frame, Unit] = { 63 | 64 | def loop(s: Stream[F, Frame], inFlightInBound: Set[Int]): Pull[F, Nothing, Unit] = 65 | s.pull.uncons1.flatMap { 66 | case Some((hd, tl)) => 67 | hd match { 68 | 69 | case PublishFrame(header: Header, topic: String, messageIdentifier: Option[Int], payload: ByteVector) => 70 | (header.qos, messageIdentifier) match { 71 | case (AtMostOnce.value, None) => 72 | Pull.eval(messageQueue.offer(Message(topic, payload.toArray.toVector))) >> 73 | loop(tl, inFlightInBound) 74 | case (AtLeastOnce.value, Some(id)) => 75 | Pull.eval( 76 | messageQueue.offer(Message(topic, payload.toArray.toVector)) >> frameQueue 77 | .offer(PubackFrame(Header(), id)) 78 | ) >> 79 | loop(tl, inFlightInBound) 80 | case (ExactlyOnce.value, Some(id)) => 81 | Pull.eval( 82 | if (inFlightInBound.contains(id)) 83 | frameQueue.offer(PubrecFrame(Header(), id)) 84 | else 85 | messageQueue.offer(Message(topic, payload.toArray.toVector)) >> frameQueue 86 | .offer(PubrecFrame(Header(), id)) 87 | ) >> 88 | loop(tl, inFlightInBound + id) 89 | case (_, _) => Pull.eval(stateSignal.set(Error(ProtocolError)) >> closeSignal.set(true)) 90 | } 91 | 92 | case PubackFrame(_: Header, messageIdentifier) => 93 | Pull.eval( 94 | inFlightOutBound.remove(messageIdentifier) >> pendingResults.remove(messageIdentifier) >>= 95 | (_.fold(Concurrent[F].unit)(_.complete(Empty).void)) 96 | ) >> 97 | loop(tl, inFlightInBound) 98 | 99 | case PubrelFrame(header, messageIdentifier) => 100 | Pull.eval(frameQueue.offer(PubcompFrame(header.copy(qos = 0), messageIdentifier))) >> 101 | loop(tl, inFlightInBound - messageIdentifier) 102 | 103 | case PubcompFrame(_, messageIdentifier) => 104 | Pull.eval( 105 | inFlightOutBound.remove(messageIdentifier) >> pendingResults.remove(messageIdentifier) >>= 106 | (_.fold(Concurrent[F].unit)(_.complete(Empty).void)) 107 | ) >> 108 | loop(tl, inFlightInBound) 109 | 110 | case PubrecFrame(header, messageIdentifier) => 111 | val pubrelFrame = PubrelFrame(header, messageIdentifier) 112 | Pull.eval( 113 | inFlightOutBound.update(messageIdentifier, pubrelFrame) >> frameQueue.offer(pubrelFrame) 114 | ) >> 115 | loop(tl, inFlightInBound) 116 | 117 | case PingRespFrame(_: Header) => 118 | Pull.eval(pingAcknowledged.set(true)) >> loop(tl, inFlightInBound) 119 | 120 | case UnsubackFrame(_: Header, messageIdentifier) => 121 | Pull.eval( 122 | inFlightOutBound.remove(messageIdentifier) >> pendingResults.remove(messageIdentifier) >>= 123 | (_.fold(Concurrent[F].unit)(_.complete(Empty).void)) 124 | ) >> 125 | loop(tl, inFlightInBound) 126 | 127 | case SubackFrame(_: Header, messageIdentifier, topics) => 128 | Pull.eval( 129 | inFlightOutBound.remove(messageIdentifier) >> pendingResults.remove(messageIdentifier) >>= 130 | (_.fold(Concurrent[F].unit)(_.complete(QoS(topics)).void)) 131 | ) >> 132 | loop(tl, inFlightInBound) 133 | 134 | case ConnackFrame(_: Header, returnCode) => 135 | if (returnCode == 0) 136 | Pull.eval(stateSignal.set(SessionStarted)) >> 137 | loop(tl, inFlightInBound) 138 | else 139 | Pull.eval( 140 | stateSignal.set( 141 | Error(ConnectionFailure(ConnectionFailureReason.fromOrdinal(returnCode))) 142 | ) >> closeSignal.set(true) 143 | ) 144 | 145 | case _ => 146 | Pull.eval(stateSignal.set(Error(ProtocolError)) >> closeSignal.set(true)) 147 | } 148 | 149 | case None => Pull.done 150 | } 151 | loop(_, Set.empty[Int]).stream 152 | } 153 | 154 | def outboundMessagesInterpreter( 155 | inFlightOutBound: AtomicMap[F, Int, Frame], 156 | stateSignal: SignallingRef[F, ConnectionState], 157 | pingTicker: Ticker[F] 158 | ): Pipe[F, Frame, Frame] = 159 | in => 160 | stateSignal.discrete.flatMap { 161 | case SessionStarted => 162 | in.flatMap { hd => 163 | (hd match { 164 | case PublishFrame(_: Header, _, messageIdentifier, _) => 165 | Stream.eval(messageIdentifier.fold(Concurrent[F].pure[Unit](()))(inFlightOutBound.update(_, hd))) 166 | case SubscribeFrame(_: Header, messageIdentifier, _) => 167 | Stream.eval(inFlightOutBound.update(messageIdentifier, hd)) 168 | case UnsubscribeFrame(_: Header, messageIdentifier, _) => 169 | Stream.eval(inFlightOutBound.update(messageIdentifier, hd)) 170 | case _ => Stream.eval(Concurrent[F].pure[Unit](())) 171 | }) >> Stream.eval(pingTicker.reset) >> Stream.emit(Some(hd)) 172 | } 173 | case Connected => 174 | Stream.emit(Some(connectFrame(sessionConfig))) ++ 175 | (if (sessionConfig.cleanSession) 176 | Stream.emit(None) 177 | else 178 | Stream 179 | .eval( 180 | inFlightOutBound.removeAll().map(m => m.map(setDupFlag).map(Some(_))).map(x => Stream.emits(x)) 181 | ) 182 | .flatten) 183 | case _ => Stream.emit(None) 184 | }.unNone 185 | 186 | def pingOrDisconnect( 187 | pingAcknowledged: Ref[F, Boolean], 188 | frameQueue: Queue[F, Frame], 189 | closeSignal: SignallingRef[F, Boolean] 190 | ): F[Unit] = 191 | pingAcknowledged.get >>= (if (_) pingAcknowledged.set(false) >> frameQueue.offer(PingReqFrame(Header())) 192 | else pingAcknowledged.set(true) >> closeSignal.set(true)) 193 | 194 | for { 195 | 196 | messageQueue <- Queue.bounded[F, Message](QUEUE_SIZE) 197 | frameQueue <- Queue.bounded[F, Frame](QUEUE_SIZE) 198 | stopSignal <- SignallingRef[F, Boolean](false) 199 | inFlightOutBound <- AtomicMap[F, Int, Frame] 200 | pendingResults <- AtomicMap[F, Int, Deferred[F, Result]] 201 | stateSignal <- SignallingRef[F, ConnectionState](Disconnected) 202 | closeSignal <- SignallingRef[F, Boolean](false) 203 | pingAcknowledged <- Ref.of[F, Boolean](true) 204 | pingTicker <- Ticker(sessionConfig.keepAlive.toLong, pingOrDisconnect(pingAcknowledged, frameQueue, closeSignal)) 205 | 206 | inbound: Pipe[F, Frame, Unit] = inboundMessagesInterpreter( 207 | messageQueue, 208 | frameQueue, 209 | inFlightOutBound, 210 | pendingResults, 211 | stateSignal, 212 | closeSignal, 213 | pingAcknowledged 214 | ) 215 | 216 | outbound: Stream[F, Frame] = 217 | Stream.fromQueueUnterminated(frameQueue).through(outboundMessagesInterpreter(inFlightOutBound, stateSignal, pingTicker)) 218 | 219 | _ <- transport(TransportConnector[F](inbound, outbound, stateSignal, closeSignal)) 220 | 221 | } yield new Protocol[F] { 222 | 223 | override def cancel: F[Unit] = stopSignal.set(true) *> pingTicker.cancel 224 | 225 | override def send: Frame => F[Unit] = frameQueue.offer 226 | 227 | override def sendReceive(frame: Frame, messageId: Int): F[Result] = 228 | for { 229 | d <- Deferred[F, Result] 230 | _ <- pendingResults.update(messageId, d) 231 | _ <- frameQueue.offer(frame) 232 | r <- d.get 233 | } yield r 234 | 235 | override val messages: Stream[F, Message] = Stream.fromQueueUnterminated(messageQueue).interruptWhen(stopSignal) 236 | 237 | override val state: SignallingRef[F, ConnectionState] = stateSignal 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/protocol/Result.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.protocol 18 | 19 | sealed trait Result 20 | object Result { 21 | case class QoS(values: Vector[Int]) extends Result 22 | 23 | case object Empty extends Result 24 | } 25 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/protocol/Ticker.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.protocol 18 | 19 | import cats.effect.{Ref, Temporal} 20 | import cats.effect.implicits._ 21 | import cats.implicits._ 22 | import fs2.Stream 23 | 24 | import java.util.concurrent.TimeUnit 25 | import scala.concurrent.duration.FiniteDuration 26 | 27 | trait Ticker[F[_]] { 28 | 29 | def reset: F[Unit] 30 | 31 | def cancel: F[Unit] 32 | 33 | } 34 | 35 | object Ticker { 36 | 37 | def apply[F[_]: Temporal](interval: Long, program: F[Unit]): F[Ticker[F]] = 38 | for { 39 | s <- Ref.of[F, Long](1) 40 | f <- 41 | (Stream.fixedRate(FiniteDuration(1, TimeUnit.SECONDS), dampen = false) >> 42 | Stream.eval(s.modify(l => (l + 1, l)))) 43 | .filter(_ % interval == 0) 44 | .evalMap(_ => program) 45 | .compile 46 | .drain 47 | .start 48 | } yield new Ticker[F] { 49 | 50 | override def reset: F[Unit] = s.set(1) 51 | 52 | override def cancel: F[Unit] = f.cancel 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/protocol/Transport.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.protocol 18 | 19 | import cats.effect.implicits._ 20 | import cats.effect.std.Console 21 | import cats.effect.{Concurrent, Fiber, Temporal} 22 | import cats.implicits._ 23 | import com.comcast.ip4s.SocketAddress 24 | import fs2.io.net.{Network, Socket} 25 | import fs2.{Pipe, Stream} 26 | import net.sigusr.impl.protocol.Direction 27 | import net.sigusr.impl.protocol.Direction.{In, Out} 28 | import net.sigusr.mqtt.api.ConnectionFailureReason.TransportError 29 | import net.sigusr.mqtt.api.ConnectionState.{Connected, Connecting, Disconnected, Error} 30 | import net.sigusr.mqtt.api.Errors.ConnectionFailure 31 | import net.sigusr.mqtt.api.{RetryConfig, TransportConfig} 32 | import net.sigusr.mqtt.impl.frames.Frame 33 | import retry.RetryDetails.{GivingUp, WillDelayAndRetry} 34 | import retry._ 35 | import scodec.Codec 36 | import fs2.interop.scodec.{StreamDecoder, StreamEncoder} 37 | 38 | import scala.concurrent.duration._ 39 | 40 | trait Transport[F[_]] {} 41 | 42 | object Transport { 43 | 44 | private def traceTLS[F[_] : Console](b: Boolean): Option[(=> String) => F[Unit]] = 45 | if (b) Some(m => Console[F].println(s"${scala.Console.MAGENTA}[TLS] $m${scala.Console.RESET}")) else None 46 | 47 | private def tracingPipe[F[_]: Concurrent : Console](d: Direction): Pipe[F, Frame, Frame] = 48 | frames => 49 | for { 50 | frame <- frames 51 | _ <- 52 | if (d.active) Stream.eval(Console[F].println(s" ${d.value} ${d.color}$frame${scala.Console.RESET}")) 53 | else Stream.eval(Concurrent[F].unit) 54 | } yield frame 55 | 56 | private def connect[F[_]: Temporal: Network: Console]( 57 | transportConfig: TransportConfig[F], 58 | connector: TransportConnector[F] 59 | ): F[Fiber[F, Throwable, Unit]] = { 60 | 61 | def outgoing(socket: Socket[F]): F[Unit] = 62 | connector.out 63 | .through(tracingPipe(Out(transportConfig.traceMessages))) 64 | .through(StreamEncoder.many[Frame](Codec[Frame].asEncoder).toPipeByte) 65 | .through(socket.writes) // TODO either use or remove these config values (transportConfig.writeTimeout) 66 | .onComplete { 67 | Stream.eval(connector.stateSignal.set(Disconnected)) 68 | } 69 | .compile 70 | .drain 71 | 72 | def incoming(socket: Socket[F]): F[Unit] = 73 | socket 74 | .reads // TODO either use or remove these config values (transportConfig.numReadBytes, transportConfig.readTimeout) 75 | .through(StreamDecoder.many[Frame](Codec[Frame].asDecoder).toPipeByte) 76 | .through(tracingPipe(In(transportConfig.traceMessages))) 77 | .through(connector.in) 78 | .onComplete { 79 | Stream.eval(connector.stateSignal.set(Disconnected)) 80 | } 81 | .compile 82 | .drain 83 | 84 | def closeSignalWatcher(socket: Socket[F]): F[Unit] = 85 | connector.closeSignal.discrete.evalMap(if (_) socket.endOfOutput else Concurrent[F].pure(())).compile.drain 86 | 87 | def loop(): F[Unit] = { 88 | 89 | val policy: RetryPolicy[F] = RetryConfig.policyOf[F](transportConfig.retryConfig) 90 | 91 | def publishError(err: Throwable, details: RetryDetails): F[Unit] = 92 | details match { 93 | case WillDelayAndRetry(nextDelay: FiniteDuration, retriesSoFar: Int, _) => 94 | connector.stateSignal.set(Connecting(nextDelay, retriesSoFar)) 95 | 96 | case GivingUp(_, _) => 97 | connector.stateSignal.set(Error(ConnectionFailure(TransportError(err)))) 98 | } 99 | 100 | def pump(socket: Socket[F]): F[Unit] = 101 | for { 102 | _ <- connector.stateSignal.set(Connected) 103 | _ <- outgoing(socket).race(incoming(socket)).race(closeSignalWatcher(socket)) 104 | } yield () 105 | 106 | 107 | 108 | retryingOnAllErrors(policy, publishError) { 109 | Network[F].client(SocketAddress(transportConfig.host, transportConfig.port)).use{ socket => 110 | transportConfig.tlsConfig.fold(pump(socket)) { tlsConfig => 111 | tlsConfig 112 | .contextOf 113 | .flatMap{ tlsContext => 114 | val builder = tlsContext.clientBuilder(socket).withParameters(tlsConfig.tlsParameters) 115 | traceTLS(transportConfig.traceMessages).fold(builder)(builder.withLogging) 116 | .build 117 | .use(pump) 118 | } 119 | } 120 | } 121 | } >> connector.stateSignal.get.flatMap { 122 | case Disconnected => connector.closeSignal.set(false) >> loop() 123 | case _ => Concurrent[F].unit 124 | } 125 | } 126 | 127 | loop().start 128 | } 129 | 130 | def apply[F[_]: Temporal: Network : Console]( 131 | transportConfig: TransportConfig[F] 132 | )(connector: TransportConnector[F]): F[Transport[F]] = 133 | for { 134 | 135 | _ <- connect(transportConfig, connector) 136 | 137 | } yield new Transport[F] {} 138 | } 139 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/protocol/TransportConnector.scala: -------------------------------------------------------------------------------- 1 | package net.sigusr.mqtt.impl.protocol 2 | 3 | import fs2.concurrent.SignallingRef 4 | import fs2.{Pipe, Stream} 5 | import net.sigusr.mqtt.api.ConnectionState 6 | import net.sigusr.mqtt.impl.frames.Frame 7 | 8 | case class TransportConnector[F[_]]( 9 | in: Pipe[F, Frame, Unit], 10 | out: Stream[F, Frame], 11 | stateSignal: SignallingRef[F, ConnectionState], 12 | closeSignal: SignallingRef[F, Boolean] 13 | ) 14 | -------------------------------------------------------------------------------- /core/src/main/scala/net/sigusr/mqtt/impl/protocol/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl 18 | 19 | package object protocol { 20 | 21 | val DEFAULT_KEEP_ALIVE: Int = 30 22 | val QUEUE_SIZE = 128 23 | 24 | } 25 | -------------------------------------------------------------------------------- /core/src/test/scala/net/sigusr/mqtt/SpecUtils.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt 18 | 19 | import org.specs2.matcher.{Expectable, MatchResult, Matcher} 20 | import scodec.{Attempt, Err} 21 | 22 | import scala.util.Random 23 | 24 | object SpecUtils { 25 | 26 | class SuccessfulAttemptMatcher[T](v: T) extends Matcher[Attempt[T]] { 27 | def apply[S <: Attempt[T]](e: Expectable[S]): MatchResult[S] = 28 | result( 29 | e.value.fold(_ => false, _ == v), 30 | s"${e.description} equals to $v", 31 | s"The result is ${e.description}, instead of the expected value '$v'", 32 | e 33 | ) 34 | } 35 | 36 | class FailedAttemptMatcher[T](m: Err) extends Matcher[Attempt[T]] { 37 | def apply[S <: Attempt[T]](e: Expectable[S]): MatchResult[S] = 38 | result( 39 | e.value.fold(_ => true, _ != m), 40 | s"${e.description} equals to $m", 41 | s"The result is ${e.description} instead of the expected error message '$m'", 42 | e 43 | ) 44 | } 45 | 46 | def succeedWith[T](t: T) = new SuccessfulAttemptMatcher[T](t) 47 | 48 | def failWith[T](t: Err) = new FailedAttemptMatcher[T](t) 49 | 50 | def makeRandomByteVector(size: Int): Vector[Byte] = { 51 | val bytes = new Array[Byte](size) 52 | Random.nextBytes(bytes) 53 | bytes.toVector 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /core/src/test/scala/net/sigusr/mqtt/impl/frames/CodecSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.frames 18 | 19 | import net.sigusr.mqtt.SpecUtils._ 20 | import net.sigusr.mqtt.api.QualityOfService.{AtLeastOnce, AtMostOnce, ExactlyOnce} 21 | import org.specs2.mutable._ 22 | import scodec.bits._ 23 | import scodec.{Codec, DecodeResult, Err, SizeBound} 24 | 25 | import scala.util.Random 26 | 27 | class CodecSpec extends Specification { 28 | 29 | "A remaining length codec" should { 30 | 31 | "Provide its size bounds" in { 32 | remainingLengthCodec.sizeBound should_=== SizeBound.bounded(8, 32) 33 | } 34 | 35 | "Perform encoding of valid inputs" in { 36 | remainingLengthCodec.encode(0) should succeedWith(hex"00".bits) 37 | remainingLengthCodec.encode(127) should succeedWith(hex"7f".bits) 38 | remainingLengthCodec.encode(128) should succeedWith(hex"8001".bits) 39 | remainingLengthCodec.encode(16383) should succeedWith(hex"ff7f".bits) 40 | remainingLengthCodec.encode(16384) should succeedWith(hex"808001".bits) 41 | remainingLengthCodec.encode(2097151) should succeedWith(hex"ffff7f".bits) 42 | remainingLengthCodec.encode(2097152) should succeedWith( 43 | hex"80808001".bits 44 | ) 45 | remainingLengthCodec.encode(268435455) should succeedWith( 46 | hex"ffffff7f".bits 47 | ) 48 | } 49 | 50 | "Fail to encode certain input values" in { 51 | remainingLengthCodec.encode(-1) should failWith( 52 | Err( 53 | s"The remaining length must be in the range [0..268435455], -1 is not valid" 54 | ) 55 | ) 56 | remainingLengthCodec.encode(268435455 + 1) should failWith( 57 | Err( 58 | s"The remaining length must be in the range [0..268435455], 268435456 is not valid" 59 | ) 60 | ) 61 | } 62 | 63 | "Perform decoding of valid inputs" in { 64 | remainingLengthCodec.decode(hex"00".bits) should succeedWith( 65 | DecodeResult(0, BitVector.empty) 66 | ) 67 | remainingLengthCodec.decode(hex"7f".bits) should succeedWith( 68 | DecodeResult(127, BitVector.empty) 69 | ) 70 | remainingLengthCodec.decode(hex"8001".bits) should succeedWith( 71 | DecodeResult(128, BitVector.empty) 72 | ) 73 | remainingLengthCodec.decode(hex"ff7f".bits) should succeedWith( 74 | DecodeResult(16383, BitVector.empty) 75 | ) 76 | remainingLengthCodec.decode(hex"808001".bits) should succeedWith( 77 | DecodeResult(16384, BitVector.empty) 78 | ) 79 | remainingLengthCodec.decode(hex"ffff7f".bits) should succeedWith( 80 | DecodeResult(2097151, BitVector.empty) 81 | ) 82 | remainingLengthCodec.decode(hex"80808001".bits) should succeedWith( 83 | DecodeResult(2097152, BitVector.empty) 84 | ) 85 | remainingLengthCodec.decode(hex"ffffff7f".bits) should succeedWith( 86 | DecodeResult(268435455, BitVector.empty) 87 | ) 88 | } 89 | 90 | "Fail to decode certain input values" in { 91 | remainingLengthCodec.decode(hex"808080807f".bits) should failWith( 92 | Err("The remaining length must be 4 bytes long at most") 93 | ) 94 | remainingLengthCodec.decode(hex"ffffff".bits) should failWith( 95 | Err.insufficientBits(8, 0) 96 | ) 97 | } 98 | } 99 | 100 | "A header codec" should { 101 | "Perform encoding of valid input" in { 102 | val header = Header(dup = false, AtLeastOnce.value, retain = true) 103 | Codec.encode(header) should succeedWith(bin"0011") 104 | } 105 | 106 | "Perform decoding of valid inputs" in { 107 | val header = Header(dup = true, ExactlyOnce.value) 108 | Codec[Header].decode(bin"1100110011") should succeedWith( 109 | DecodeResult(header, bin"110011") 110 | ) 111 | } 112 | } 113 | 114 | "A connect variable header codec" should { 115 | "Perform encoding of valid inputs" in { 116 | 117 | import net.sigusr.mqtt.impl.frames.ConnectVariableHeader._ 118 | 119 | val connectVariableHeader = ConnectVariableHeader( 120 | cleanSession = true, 121 | willFlag = true, 122 | willQoS = AtMostOnce.value, 123 | willRetain = false, 124 | passwordFlag = true, 125 | userNameFlag = true, 126 | keepAliveTimer = 1024 127 | ) 128 | val res = connectVariableHeaderFixedBytes ++ bin"110001100000010000000000" 129 | Codec.encode(connectVariableHeader) should succeedWith(res) 130 | } 131 | 132 | "Perform decoding of valid inputs" in { 133 | 134 | import net.sigusr.mqtt.impl.frames.ConnectVariableHeader._ 135 | 136 | val res = ConnectVariableHeader( 137 | cleanSession = false, 138 | willFlag = false, 139 | willQoS = AtLeastOnce.value, 140 | willRetain = true, 141 | passwordFlag = false, 142 | userNameFlag = false, 143 | keepAliveTimer = 12683 144 | ) 145 | Codec[ConnectVariableHeader].decode( 146 | connectVariableHeaderFixedBytes ++ bin"001010000011000110001011101010" 147 | ) should succeedWith(DecodeResult(res, bin"101010")) 148 | } 149 | } 150 | 151 | "A connect message codec should" should { 152 | "[0] Perform round trip encoding/decoding of a valid input" in { 153 | val header = Header(dup = false, AtMostOnce.value) 154 | val connectVariableHeader = ConnectVariableHeader( 155 | userNameFlag = true, 156 | passwordFlag = true, 157 | willRetain = true, 158 | AtLeastOnce.value, 159 | willFlag = true, 160 | cleanSession = true, 161 | 15 162 | ) 163 | val connectMessage = ConnectFrame( 164 | header, 165 | connectVariableHeader, 166 | "clientId", 167 | Some("Topic"), 168 | Some("Message"), 169 | Some("User"), 170 | Some("Password") 171 | ) 172 | 173 | val valid = Codec[Frame].encode(connectMessage).require 174 | Codec[Frame].decode(valid) should succeedWith( 175 | DecodeResult(connectMessage, bin"") 176 | ) 177 | } 178 | 179 | "[1] Perform round trip encoding/decoding of a valid input" in { 180 | val header = Header(dup = false, AtMostOnce.value) 181 | val connectVariableHeader = ConnectVariableHeader( 182 | userNameFlag = true, 183 | passwordFlag = false, 184 | willRetain = true, 185 | AtLeastOnce.value, 186 | willFlag = false, 187 | cleanSession = true, 188 | 15 189 | ) 190 | val connectMessage = ConnectFrame( 191 | header, 192 | connectVariableHeader, 193 | "clientId", 194 | None, 195 | None, 196 | Some("User"), 197 | None 198 | ) 199 | 200 | Codec[Frame].decode( 201 | Codec[Frame].encode(connectMessage).require 202 | ) should succeedWith(DecodeResult(connectMessage, bin"")) 203 | } 204 | 205 | "[2] Perform round trip encoding/decoding of a valid input" in { 206 | val header = Header(dup = false, AtMostOnce.value) 207 | val connectVariableHeader = ConnectVariableHeader( 208 | userNameFlag = false, 209 | passwordFlag = false, 210 | willRetain = true, 211 | ExactlyOnce.value, 212 | willFlag = false, 213 | cleanSession = false, 214 | 128 215 | ) 216 | val connectMessage = ConnectFrame( 217 | header, 218 | connectVariableHeader, 219 | "clientId", 220 | None, 221 | None, 222 | None, 223 | None 224 | ) 225 | 226 | Codec[Frame].decode( 227 | Codec[Frame].encode(connectMessage).require 228 | ) should succeedWith(DecodeResult(connectMessage, bin"")) 229 | } 230 | 231 | "Perform encoding and match a captured value" in { 232 | val header = Header(dup = false, AtMostOnce.value) 233 | val connectVariableHeader = ConnectVariableHeader( 234 | userNameFlag = false, 235 | passwordFlag = false, 236 | willRetain = true, 237 | AtLeastOnce.value, 238 | willFlag = true, 239 | cleanSession = false, 240 | 60 241 | ) 242 | val connectMessage = ConnectFrame( 243 | header, 244 | connectVariableHeader, 245 | "test", 246 | Some("test/topic"), 247 | Some("test death"), 248 | None, 249 | None 250 | ) 251 | 252 | val capture = BitVector(0x10, 0x2a, 0x00, 0x06, 0x4d, 0x51, 0x49, 0x73, 0x64, 0x70, 0x03, 0x2c, 0x00, 0x3c, 0x00, 253 | 0x04, 0x74, 0x65, 0x73, 0x74, 0x00, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x2f, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x00, 254 | 0x0a, 0x74, 0x65, 0x73, 0x74, 0x20, 0x64, 0x65, 0x61, 0x74, 0x68) 255 | Codec[Frame].encode(connectMessage) should succeedWith(capture) 256 | } 257 | } 258 | 259 | "A connack message codec" should { 260 | "Perform round trip encoding/decoding of a valid input" in { 261 | val header = Header(dup = false, AtMostOnce.value) 262 | val connackFrame = ConnackFrame(header, 0) 263 | 264 | Codec[Frame].decode( 265 | Codec[Frame].encode(connackFrame).require 266 | ) should succeedWith(DecodeResult(connackFrame, bin"")) 267 | } 268 | 269 | "Perform decoding of captured values" in { 270 | val header = Header(dup = false, AtMostOnce.value) 271 | val connackFrame = ConnackFrame(header, 0) 272 | 273 | Codec[Frame].decode(BitVector(0x20, 0x02, 0x00, 0x00)) should succeedWith( 274 | DecodeResult(connackFrame, bin"") 275 | ) 276 | } 277 | } 278 | 279 | "A topics codec" should { 280 | "Perform round trip encoding/decoding of a valid input" in { 281 | import net.sigusr.mqtt.impl.frames.SubscribeFrame.topicsCodec 282 | val topics = Vector( 283 | ("topic0", AtMostOnce.value), 284 | ("topic1", AtLeastOnce.value), 285 | ("topic2", ExactlyOnce.value) 286 | ) 287 | Codec[Vector[(String, Int)]].decode( 288 | Codec[Vector[(String, Int)]].encode(topics).require 289 | ) should succeedWith(DecodeResult(topics, bin"")) 290 | } 291 | } 292 | 293 | "A subscribe codec" should { 294 | "Perform round trip encoding/decoding of a valid input" in { 295 | val header = Header(dup = false, AtLeastOnce.value) 296 | val topics = Vector( 297 | ("topic0", AtMostOnce.value), 298 | ("topic1", AtLeastOnce.value), 299 | ("topic2", ExactlyOnce.value) 300 | ) 301 | val subscribeFrame = SubscribeFrame(header, 3, topics) 302 | Codec[Frame].decode( 303 | Codec[Frame].encode(subscribeFrame).require 304 | ) should succeedWith(DecodeResult(subscribeFrame, bin"")) 305 | } 306 | 307 | "Perform encoding and match a captured value" in { 308 | val header = Header(dup = false, AtLeastOnce.value) 309 | val topics = Vector(("topic", AtLeastOnce.value)) 310 | val subscribeFrame = SubscribeFrame(header, 1, topics) 311 | val capture = 312 | BitVector(0x82, 0x0a, 0x00, 0x01, 0x00, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x01) 313 | Codec[Frame].encode(subscribeFrame) should succeedWith(capture) 314 | } 315 | } 316 | 317 | "A suback codec" should { 318 | "Perform round trip encoding/decoding of a valid input" in { 319 | val header = Header(dup = false, AtLeastOnce.value) 320 | val qos = Vector(AtMostOnce.value, AtLeastOnce.value, ExactlyOnce.value) 321 | val subackFrame = SubackFrame(header, 3, qos) 322 | Codec[Frame].decode( 323 | Codec[Frame].encode(subackFrame).require 324 | ) should succeedWith(DecodeResult(subackFrame, bin"")) 325 | } 326 | } 327 | 328 | "An unsubscribe codec" should { 329 | "Perform round trip encoding/decoding of a valid input" in { 330 | val header = Header(dup = false, AtLeastOnce.value) 331 | val topics = Vector("topic0", "topic1") 332 | val unsubscribeFrame = 333 | UnsubscribeFrame(header, Random.nextInt(65536), topics) 334 | Codec[Frame].decode( 335 | Codec[Frame].encode(unsubscribeFrame).require 336 | ) should succeedWith(DecodeResult(unsubscribeFrame, bin"")) 337 | } 338 | } 339 | 340 | "An unsuback codec" should { 341 | "Perform round trip encoding/decoding of a valid input" in { 342 | val header = Header(dup = false, AtLeastOnce.value) 343 | val unsubackFrame = UnsubackFrame(header, Random.nextInt(65536)) 344 | Codec[Frame].decode( 345 | Codec[Frame].encode(unsubackFrame).require 346 | ) should succeedWith(DecodeResult(unsubackFrame, bin"")) 347 | } 348 | } 349 | 350 | "A disconnect message codec" should { 351 | "Perform round trip encoding/decoding of a valid input" in { 352 | val header = Header(dup = false, AtMostOnce.value) 353 | val disconnectFrame = DisconnectFrame(header) 354 | 355 | Codec[Frame].decode( 356 | Codec[Frame].encode(disconnectFrame).require 357 | ) should succeedWith(DecodeResult(disconnectFrame, bin"")) 358 | } 359 | } 360 | 361 | "A ping request message codec" should { 362 | "Perform round trip encoding/decoding of a valid input" in { 363 | val header = Header(dup = false, AtMostOnce.value) 364 | val pingReqFrame = PingReqFrame(header) 365 | 366 | Codec[Frame].decode( 367 | Codec[Frame].encode(pingReqFrame).require 368 | ) should succeedWith(DecodeResult(pingReqFrame, bin"")) 369 | } 370 | 371 | "Perform encoding and match a captured value" in { 372 | val header = Header(dup = false, AtMostOnce.value) 373 | val connectMessage = PingReqFrame(header) 374 | 375 | val capture = BitVector(0xc0, 0x00) 376 | Codec[Frame].encode(connectMessage) should succeedWith(capture) 377 | } 378 | } 379 | 380 | "A ping response message codec" should { 381 | "Perform round trip encoding/decoding of a valid input" in { 382 | val header = Header(dup = false, AtMostOnce.value) 383 | val pingRespFrame = PingRespFrame(header) 384 | 385 | Codec[Frame].decode( 386 | Codec[Frame].encode(pingRespFrame).require 387 | ) should succeedWith(DecodeResult(pingRespFrame, bin"")) 388 | } 389 | 390 | "Perform decoding of captured values" in { 391 | val header = Header(dup = false, AtMostOnce.value) 392 | val pingRespFrame = PingRespFrame(header) 393 | 394 | Codec[Frame].decode(BitVector(0xd0, 0x00)) should succeedWith( 395 | DecodeResult(pingRespFrame, bin"") 396 | ) 397 | } 398 | } 399 | 400 | "A publish message codec" should { 401 | "Perform round trip encoding/decoding of a valid input with a QoS greater than 0" in { 402 | val header = Header(dup = false, AtLeastOnce.value) 403 | val topic = "a/b" 404 | val publishFrame = PublishFrame( 405 | header, 406 | topic, 407 | Some(10), 408 | ByteVector("Hello world".getBytes) 409 | ) 410 | 411 | Codec[Frame].decode( 412 | Codec[Frame].encode(publishFrame).require 413 | ) should succeedWith(DecodeResult(publishFrame, bin"")) 414 | } 415 | 416 | "Perform round trip encoding/decoding of a valid input with a QoS equals to 0" in { 417 | val header = Header(dup = false, AtMostOnce.value) 418 | val topic = "a/b" 419 | val publishFrame = 420 | PublishFrame(header, topic, None, ByteVector("Hello world".getBytes)) 421 | 422 | Codec[Frame].decode( 423 | Codec[Frame].encode(publishFrame).require 424 | ) should succeedWith(DecodeResult(publishFrame, bin"")) 425 | } 426 | } 427 | 428 | "A message codec" should { 429 | "Fail if there is not enough bytes to decode" in { 430 | val header = Header(dup = false, AtLeastOnce.value) 431 | val topic = "a/b" 432 | val publishFrame = PublishFrame( 433 | header, 434 | topic, 435 | Some(10), 436 | ByteVector(makeRandomByteVector(256)) 437 | ) 438 | 439 | val bitVector = Codec[Frame].encode(publishFrame).require 440 | val head = bitVector.take(56 * 8) 441 | Codec[Frame].decode(head) should failWith(Err.insufficientBits(2104, 424)) 442 | } 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /core/src/test/scala/net/sigusr/mqtt/impl/frames/SetDupFlagSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.impl.frames 18 | 19 | import net.sigusr.mqtt.SpecUtils.makeRandomByteVector 20 | import net.sigusr.mqtt.api.QualityOfService.{AtLeastOnce, AtMostOnce, ExactlyOnce} 21 | import org.specs2.mutable.Specification 22 | import scodec.bits.ByteVector 23 | 24 | class SetDupFlagSpec extends Specification { 25 | 26 | "The 'setDupFlag' utility function" should { 27 | 28 | val header = Header(dup = false, AtLeastOnce.value) 29 | val dupHeader = Header(dup = true, AtLeastOnce.value) 30 | 31 | "Set the dup flag for PublishFrame" in { 32 | val topic = "a/b" 33 | val payload = ByteVector(makeRandomByteVector(256)) 34 | val publishFrame = PublishFrame(header, topic, Some(10), payload) 35 | 36 | val duped = setDupFlag(publishFrame).asInstanceOf[PublishFrame] 37 | 38 | duped.header should_=== dupHeader 39 | duped.topic should_=== topic 40 | duped.payload should_=== payload 41 | duped.messageIdentifier should_=== Some(10) 42 | 43 | } 44 | 45 | "Set the dup flag for PubrelFrame" in { 46 | val pubrelFrame = PubrelFrame(header, 10) 47 | 48 | val duped = setDupFlag(pubrelFrame).asInstanceOf[PubrelFrame] 49 | 50 | duped.header should_=== dupHeader 51 | duped.messageIdentifier should_=== 10 52 | 53 | } 54 | 55 | "Set the dup flag for SubscribeFrame" in { 56 | val header = Header(dup = false, AtLeastOnce.value) 57 | val topics = Vector( 58 | ("topic0", AtMostOnce.value), 59 | ("topic1", AtLeastOnce.value), 60 | ("topic2", ExactlyOnce.value) 61 | ) 62 | val subscribeFrame = SubscribeFrame(header, 3, topics) 63 | val duped = setDupFlag(subscribeFrame).asInstanceOf[SubscribeFrame] 64 | 65 | duped.header should_=== dupHeader 66 | duped.messageIdentifier should_=== 3 67 | duped.topics should_=== topics 68 | } 69 | 70 | "Set the dup flag for UnsubscribeFrame" in { 71 | val topics = Vector("topic0", "topic1") 72 | val unsubscribeFrame = 73 | UnsubscribeFrame(header, 10, topics) 74 | 75 | val duped = setDupFlag(unsubscribeFrame).asInstanceOf[UnsubscribeFrame] 76 | 77 | duped.header should_=== dupHeader 78 | duped.messageIdentifier should_=== 10 79 | duped.topics should_=== topics 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /core/src/test/scala/net/sigusr/mqtt/impl/protocol/IdGeneratorSpec.scala: -------------------------------------------------------------------------------- 1 | package net.sigusr.mqtt.impl.protocol 2 | 3 | import cats.effect.IO 4 | import cats.effect.testing.specs2.CatsEffect 5 | import org.specs2.mutable._ 6 | 7 | // http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html#_Toc398718025 8 | class IdGeneratorSpec extends Specification with CatsEffect { 9 | 10 | "An id generator" should { 11 | 12 | "Provide ids > 0" in { 13 | IdGenerator[IO]().flatMap(gen => 14 | gen.next.replicateA(100*1000) 15 | .map(ids => ids.forall(_ > 0) must beTrue) 16 | ) 17 | } 18 | 19 | "Provide ids <= 65535" in { 20 | IdGenerator[IO]().flatMap(gen => 21 | gen.next.replicateA(100*1000) 22 | .map(ids => ids.forall(_ <= 65535) must beTrue) 23 | ) 24 | } 25 | 26 | "Provide 65535 different ids" in { 27 | IdGenerator[IO]().flatMap(gen => 28 | gen.next.replicateA(65535) 29 | .map(ids => ids.distinct.size must_== ids.size) 30 | ) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /core/src/test/scala/net/sigusr/mqtt/impl/protocol/TickerSpec.scala: -------------------------------------------------------------------------------- 1 | package net.sigusr.mqtt.impl.protocol 2 | 3 | import cats.effect.testing.specs2.CatsEffect 4 | import cats.effect.testkit.TestControl 5 | import cats.effect.{IO, Ref} 6 | import org.specs2.mutable._ 7 | 8 | import scala.concurrent.duration.DurationInt 9 | 10 | class TickerSpec extends Specification with CatsEffect { 11 | 12 | def steps[T](control: TestControl[T], count: Int): IO[Unit] = { 13 | val io = control.nextInterval flatMap control.advanceAndTick 14 | if (count == 1) io else io.flatMap(_ => steps(control, count - 1)) 15 | } 16 | 17 | "A ticker" should { 18 | 19 | "Trigger a program only when a given time is elapsed" in { 20 | for { 21 | ref <- Ref[IO].of(false) 22 | control <- TestControl.execute(Ticker[IO](30, ref.set(true))) 23 | _ <- control.tick 24 | _ <- steps(control, 29) 25 | result0 <- ref.get.map(_ must beFalse) 26 | _ <- steps(control, 30) 27 | result1 <- ref.get.map(_ must beTrue) 28 | } yield result0.and(result1) 29 | } 30 | 31 | "Trigger a program after an extended elapsed time when it has been reset" in { 32 | for { 33 | ref <- Ref[IO].of(false) 34 | control <- TestControl.execute( 35 | Ticker[IO](30, ref.set(true)) 36 | .flatMap { t => 37 | IO.sleep(20.seconds) *> t.reset 38 | } 39 | ) 40 | _ <- control.tick 41 | _ <- steps(control, 30) 42 | result <- ref.get.map(_ must beFalse) 43 | _ <- steps(control, 20) 44 | result2 <- ref.get.map(_ must beTrue) 45 | } yield result.and(result2) 46 | } 47 | 48 | "Not Trigger a program when canceled before a given time is elapsed" in { 49 | for { 50 | ref <- Ref[IO].of(false) 51 | control <- TestControl.execute { 52 | // This is needed to guaranty there is an available task to 53 | // schedule even when the second task is canceled 54 | IO.sleep(30.seconds).racePair( 55 | Ticker[IO](30, ref.set(true)) 56 | .flatMap { t => 57 | IO.sleep(29.seconds) *> t.cancel 58 | }) 59 | } 60 | _ <- control.tick 61 | _ <- steps(control, 30) 62 | result <- ref.get.map(_ must beFalse) 63 | } yield result 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/src/main/scala/net/sigusr/mqtt/examples/LocalPublisher.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITTaskNS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.examples 18 | 19 | import cats.effect.{ExitCode, IO, IOApp} 20 | import cats.effect.std.Console 21 | import cats.implicits._ 22 | import com.comcast.ip4s.{Host, Port} 23 | import fs2.Stream 24 | import net.sigusr.mqtt.api.QualityOfService.{AtLeastOnce, AtMostOnce, ExactlyOnce} 25 | import net.sigusr.mqtt.api._ 26 | 27 | import scala.concurrent.duration._ 28 | import scala.util.Random 29 | 30 | object LocalPublisher extends IOApp { 31 | 32 | private val random: Stream[IO, Int] = Stream.eval(IO(Math.abs(Random.nextInt()))).repeat 33 | private val topics = 34 | Stream(("AtMostOnce", AtMostOnce), ("AtLeastOnce", AtLeastOnce), ("ExactlyOnce", ExactlyOnce)).repeat 35 | 36 | override def run(args: List[String]): IO[ExitCode] = 37 | if (args.nonEmpty) { 38 | val messages = args.toVector 39 | val transportConfig = 40 | TransportConfig[IO]( 41 | Host.fromString("localhost").get, 42 | Port.fromString("1883").get, 43 | // TLS support looks like 44 | // 8883, 45 | // tlsConfig = Some(TLSConfig(TLSContextKind.System)), 46 | traceMessages = true 47 | ) 48 | val sessionConfig = 49 | SessionConfig(s"$localPublisher", user = Some(localPublisher), password = Some("yala"), keepAlive = 5) 50 | implicit val console: Console[IO] = Console.make[IO] 51 | Session[IO](transportConfig, sessionConfig).use { session => 52 | val sessionStatus = session.state.discrete 53 | .evalMap(logSessionStatus[IO]) 54 | .evalMap(onSessionError[IO]) 55 | .compile 56 | .drain 57 | val publisher = (for { 58 | m <- ticks().zipRight(randomMessage(messages).zip(topics)) 59 | message = m._1 60 | topic = m._2._1 61 | qos = m._2._2 62 | _ <- Stream.eval( 63 | putStrLn[IO]( 64 | s"Publishing on topic ${scala.Console.CYAN}$topic${scala.Console.RESET} with QoS " + 65 | s"${scala.Console.CYAN}${qos.show}${scala.Console.RESET} message ${scala.Console.BOLD}$message${scala.Console.RESET}" 66 | ) 67 | ) 68 | _ <- Stream.eval(session.publish(topic, payload(message), qos)) 69 | } yield ()).compile.drain 70 | 71 | for { 72 | _ <- IO.race(publisher, sessionStatus) 73 | } yield ExitCode.Success 74 | }.handleErrorWith(_ => IO.pure(ExitCode.Error)) 75 | } else 76 | putStrLn[IO](s"${scala.Console.RED}At least one or more « messages » should be provided.${scala.Console.RESET}") 77 | .as(ExitCode.Error) 78 | 79 | private def ticks(): Stream[IO, Unit] = 80 | random.flatMap { r => 81 | val interval = r % 2000 + 1000 82 | Stream.sleep[IO](interval.milliseconds) 83 | } 84 | 85 | private def randomMessage(messages: Vector[String]): Stream[IO, String] = 86 | random.flatMap(r => Stream.emit(messages(r % messages.length))) 87 | } 88 | -------------------------------------------------------------------------------- /examples/src/main/scala/net/sigusr/mqtt/examples/LocalSubscriber.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt.examples 18 | 19 | import cats.effect.std.Console 20 | import cats.effect.{ExitCode, IO, IOApp} 21 | import cats.implicits._ 22 | import com.comcast.ip4s.{Host, Port} 23 | import fs2.Stream 24 | import fs2.concurrent.SignallingRef 25 | import net.sigusr.mqtt.api.QualityOfService.{AtLeastOnce, AtMostOnce, ExactlyOnce} 26 | import net.sigusr.mqtt.api.RetryConfig.Custom 27 | import net.sigusr.mqtt.api._ 28 | import retry.RetryPolicies 29 | 30 | import java.util.concurrent.TimeUnit 31 | import scala.concurrent.duration.{FiniteDuration, SECONDS} 32 | 33 | object LocalSubscriber extends IOApp { 34 | 35 | private val stopTopic: String = s"$localSubscriber/stop" 36 | private val subscribedTopics: Vector[(String, QualityOfService)] = Vector( 37 | (stopTopic, ExactlyOnce), 38 | ("AtMostOnce", AtMostOnce), 39 | ("AtLeastOnce", AtLeastOnce), 40 | ("ExactlyOnce", ExactlyOnce) 41 | ) 42 | 43 | private val unsubscribedTopics: Vector[String] = Vector("AtMostOnce", "AtLeastOnce", "ExactlyOnce") 44 | 45 | override def run(args: List[String]): IO[ExitCode] = { 46 | val retryConfig: Custom[IO] = Custom[IO]( 47 | RetryPolicies 48 | .limitRetries[IO](5) 49 | .join(RetryPolicies.fullJitter[IO](FiniteDuration(2, SECONDS))) 50 | ) 51 | val transportConfig = 52 | TransportConfig[IO]( 53 | Host.fromString("localhost").get, 54 | Port.fromString("1883").get, 55 | // TLS support looks like 56 | // 8883, 57 | // tlsConfig = Some(TLSConfig(TLSContextKind.System)), 58 | retryConfig = retryConfig, 59 | traceMessages = true 60 | ) 61 | val sessionConfig = 62 | SessionConfig( 63 | s"$localSubscriber", 64 | cleanSession = false, 65 | user = Some(localSubscriber), 66 | password = Some("yolo"), 67 | keepAlive = 5 68 | ) 69 | implicit val console: Console[IO] = Console.make[IO] 70 | Session[IO](transportConfig, sessionConfig).use { session => 71 | SignallingRef[IO, Boolean](false).flatMap { stopSignal => 72 | val sessionStatus = session.state.discrete 73 | .evalMap(logSessionStatus[IO]) 74 | .evalMap(onSessionError[IO]) 75 | .interruptWhen(stopSignal) 76 | .compile 77 | .drain 78 | val subscriber = for { 79 | s <- session.subscribe(subscribedTopics) 80 | _ <- s.traverse { p => 81 | putStrLn[IO]( 82 | s"Topic ${scala.Console.CYAN}${p._1}${scala.Console.RESET} subscribed with QoS " + 83 | s"${scala.Console.CYAN}${p._2.show}${scala.Console.RESET}" 84 | ) 85 | } 86 | _ <- IO.sleep(FiniteDuration(23, TimeUnit.SECONDS)) 87 | _ <- session.unsubscribe(unsubscribedTopics) 88 | _ <- 89 | putStrLn[IO](s"Topic ${scala.Console.CYAN}${unsubscribedTopics.mkString(", ")}${scala.Console.RESET} unsubscribed") 90 | _ <- stopSignal.discrete.compile.drain 91 | } yield () 92 | val reader = session.messages.flatMap(processMessages(stopSignal)).interruptWhen(stopSignal).compile.drain 93 | for { 94 | _ <- IO.racePair(sessionStatus, subscriber.race(reader)) 95 | } yield ExitCode.Success 96 | } 97 | }.handleErrorWith(_ => IO.pure(ExitCode.Error)) 98 | } 99 | 100 | private def processMessages(stopSignal: SignallingRef[IO, Boolean]): Message => Stream[IO, Unit] = { 101 | case Message(LocalSubscriber.stopTopic, _) => Stream.exec(stopSignal.set(true)) 102 | case Message(topic, payload) => 103 | Stream.eval( 104 | putStrLn[IO]( 105 | s"Topic ${scala.Console.CYAN}$topic${scala.Console.RESET}: " + 106 | s"${scala.Console.BOLD}${new String(payload.toArray, "UTF-8")}${scala.Console.RESET}" 107 | ) 108 | ) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /examples/src/main/scala/net/sigusr/mqtt/examples/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014 Frédéric Cabestre 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.sigusr.mqtt 18 | 19 | import cats.effect.Sync 20 | import cats.implicits._ 21 | import net.sigusr.mqtt.api.ConnectionState 22 | import net.sigusr.mqtt.api.ConnectionState.{Connected, Connecting, Disconnected, Error, SessionStarted} 23 | import net.sigusr.mqtt.api.Errors.{ConnectionFailure, ProtocolError} 24 | 25 | package object examples { 26 | val localSubscriber: String = "Local-Subscriber" 27 | val localPublisher: String = "Local-Publisher" 28 | 29 | val payload: String => Vector[Byte] = (_: String).getBytes("UTF-8").toVector 30 | 31 | def logSessionStatus[F[_]: Sync]: ConnectionState => F[ConnectionState] = 32 | s => 33 | (s match { 34 | case Error(ConnectionFailure(reason)) => 35 | putStrLn(s"${Console.RED}${reason.show}${Console.RESET}") 36 | case Error(ProtocolError) => 37 | putStrLn(s"${Console.RED}Ṕrotocol error${Console.RESET}") 38 | case Disconnected => 39 | putStrLn(s"${Console.BLUE}Transport disconnected${Console.RESET}") 40 | case Connecting(nextDelay, retriesSoFar) => 41 | putStrLn( 42 | s"${Console.BLUE}Transport connecting. $retriesSoFar attempt(s) so far, next attempt in $nextDelay ${Console.RESET}" 43 | ) 44 | case Connected => 45 | putStrLn(s"${Console.BLUE}Transport connected${Console.RESET}") 46 | case SessionStarted => 47 | putStrLn(s"${Console.BLUE}Session started${Console.RESET}") 48 | }) >> Sync[F].pure(s) 49 | 50 | def putStrLn[F[_]: Sync](s: String): F[Unit] = Sync[F].delay(println(s)) 51 | 52 | def onSessionError[F[_]: Sync]: ConnectionState => F[Unit] = { 53 | case Error(e) => Sync[F].raiseError(e) 54 | case _ => Sync[F].pure(()) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | #Activator-generated Properties 2 | #Mon May 12 09:07:10 CEST 2014 3 | template.uuid=7259c720-f2cc-437f-b85b-61610508bb5f 4 | sbt.version=1.10.11 5 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.3.0") 2 | addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1032048a") 3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") 4 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.9.3") 5 | --------------------------------------------------------------------------------