Skip to content

Commit 9559d7e

Browse files
authored
Merge pull request #630 from agilesteel/zio-json
feat: add zio-json support
2 parents 57a0230 + 2f29e34 commit 9559d7e

File tree

5 files changed

+234
-2
lines changed

5 files changed

+234
-2
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
- run: sbt '++ ${{ matrix.scala }}' test docs/mdoc mimaReportBinaryIssues
5454

5555
- name: Compress target directories
56-
run: tar cf targets.tar oauth2-jsoniter/jvm/target oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target oauth2-cache-zio/target oauth2-jsoniter/js/target target oauth2-cache-scalacache/target mdoc/target oauth2-circe/jvm/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-circe/js/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target
56+
run: tar cf targets.tar oauth2-jsoniter/jvm/target oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target oauth2-cache-zio/target oauth2-jsoniter/js/target target oauth2-cache-scalacache/target mdoc/target oauth2-circe/jvm/target oauth2-cache-cats/target oauth2-zio-json/js/target oauth2-cache-future/jvm/target oauth2-circe/js/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2-zio-json/jvm/target oauth2/jvm/target project/target
5757

5858
- name: Upload target directories
5959
uses: actions/upload-artifact@v5

build.sbt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import sbtghactions.UseRef
22

3+
Global / onChangedBuildSource := ReloadOnSourceChanges
4+
35
inThisBuild(
46
List(
57
organization := "org.polyvariant",
@@ -49,6 +51,7 @@ val Versions = new {
4951
val catsEffect2 = "2.5.5"
5052
val circe = "0.14.9"
5153
val jsoniter = "2.30.15"
54+
val zioJson = "0.7.45"
5255
val monix = "3.4.1"
5356
val scalaTest = "3.2.19"
5457
val sttp = "4.0.3"
@@ -128,6 +131,27 @@ lazy val `oauth2-jsoniter` = crossProject(JSPlatform, JVMPlatform)
128131
)
129132
.dependsOn(oauth2 % "compile->compile;test->test")
130133

134+
lazy val `oauth2-zio-json` = crossProject(JSPlatform, JVMPlatform)
135+
.withoutSuffixFor(JVMPlatform)
136+
.in(file("oauth2-zio-json"))
137+
.settings(
138+
name := "sttp-oauth2-zio-json",
139+
libraryDependencies ++= Seq(
140+
"dev.zio" %%% "zio-json" % Versions.zioJson
141+
),
142+
// zio-json-macros only available for Scala 2.x (provides @jsonField for Scala 2)
143+
// For Scala 3, @jsonField is in zio-json core
144+
libraryDependencies ++= (
145+
if (scalaVersion.value.startsWith("3")) Seq.empty
146+
else Seq("dev.zio" %%% "zio-json-macros" % Versions.zioJson)
147+
),
148+
mimaSettings,
149+
compilerPlugins,
150+
// zio-json 0.7.45 pulls in scala-library 2.13.17, allow upgrade for Scala 2.13
151+
allowUnsafeScalaLibUpgrade := true
152+
)
153+
.dependsOn(oauth2 % "compile->compile;test->test")
154+
131155
lazy val docs = project
132156
.in(file("mdoc")) // important: it must not be docs/
133157
.settings(
@@ -257,5 +281,7 @@ val root = project
257281
`oauth2-circe`.jvm,
258282
`oauth2-circe`.js,
259283
`oauth2-jsoniter`.jvm,
260-
`oauth2-jsoniter`.js
284+
`oauth2-jsoniter`.js,
285+
`oauth2-zio-json`.jvm,
286+
`oauth2-zio-json`.js
261287
)
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package org.polyvariant.sttp.oauth2.json.ziojson
2+
3+
import org.polyvariant.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse
4+
import org.polyvariant.sttp.oauth2.ExtendedOAuth2TokenResponse
5+
import org.polyvariant.sttp.oauth2.Introspection.Audience
6+
import org.polyvariant.sttp.oauth2.Introspection.SeqAudience
7+
import org.polyvariant.sttp.oauth2.Introspection.StringAudience
8+
import org.polyvariant.sttp.oauth2.Introspection.TokenIntrospectionResponse
9+
import org.polyvariant.sttp.oauth2.OAuth2TokenResponse
10+
import org.polyvariant.sttp.oauth2.RefreshTokenResponse
11+
import org.polyvariant.sttp.oauth2.Secret
12+
import org.polyvariant.sttp.oauth2.TokenUserDetails
13+
import org.polyvariant.sttp.oauth2.UserInfo
14+
import org.polyvariant.sttp.oauth2.common.Error.OAuth2Error
15+
import org.polyvariant.sttp.oauth2.common.Scope
16+
import org.polyvariant.sttp.oauth2.json.{JsonDecoder => OAuth2JsonDecoder}
17+
import zio.json.SnakeCase
18+
import zio.json._
19+
import zio.json.jsonMemberNames
20+
21+
import java.time.Instant
22+
import scala.concurrent.duration.DurationLong
23+
import scala.concurrent.duration.FiniteDuration
24+
25+
trait ZioJsonDecoders {
26+
import ZioJsonDecoders._
27+
28+
implicit def jsonDecoder[A](
29+
implicit decoder: JsonDecoder[A]
30+
): OAuth2JsonDecoder[A] =
31+
(data: String) => decoder.decodeJson(data).left.map(msg => OAuth2JsonDecoder.Error(msg))
32+
33+
implicit val userInfoDecoder: JsonDecoder[UserInfo] =
34+
userInfoRawDecoder.map { raw =>
35+
UserInfo(
36+
raw.sub,
37+
raw.name,
38+
raw.givenName,
39+
raw.familyName,
40+
raw.jobTitle,
41+
raw.domain,
42+
raw.preferredUsername,
43+
raw.email,
44+
raw.emailVerified,
45+
raw.locale,
46+
raw.sites.getOrElse(Nil),
47+
raw.banners.getOrElse(Nil),
48+
raw.regions.getOrElse(Nil),
49+
raw.fulfillmentContexts.getOrElse(Nil)
50+
)
51+
}
52+
53+
implicit val accessTokenResponseDecoder: JsonDecoder[AccessTokenResponse] =
54+
accessTokenResponseRawDecoder.mapOrFail { raw =>
55+
if (raw.tokenType.equalsIgnoreCase("Bearer"))
56+
Right(AccessTokenResponse(raw.accessToken, raw.domain, raw.expiresIn, raw.scope))
57+
else
58+
Left(s"Error while decoding '.token_type': value '${raw.tokenType}' is not equal to 'Bearer'")
59+
}
60+
61+
implicit val oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] =
62+
oAuth2ErrorRawDecoder.map { raw =>
63+
OAuth2Error.fromErrorTypeAndDescription(raw.error, raw.errorDescription)
64+
}
65+
66+
implicit val oAuth2TokenResponseDecoder: JsonDecoder[OAuth2TokenResponse] =
67+
oAuth2TokenResponseDecoderImpl
68+
69+
implicit val extendedOAuth2TokenResponseDecoder: JsonDecoder[ExtendedOAuth2TokenResponse] =
70+
extendedOAuth2TokenResponseDecoderImpl
71+
72+
implicit val tokenIntrospectionResponseDecoder: JsonDecoder[TokenIntrospectionResponse] =
73+
tokenIntrospectionResponseDecoderImpl
74+
75+
implicit val refreshTokenResponseDecoder: JsonDecoder[RefreshTokenResponse] =
76+
refreshTokenResponseDecoderImpl
77+
78+
}
79+
80+
object ZioJsonDecoders {
81+
82+
private[ziojson] implicit val secretStringDecoder: JsonDecoder[Secret[String]] =
83+
JsonDecoder.string.map(Secret(_))
84+
85+
private[ziojson] implicit val secondsDecoder: JsonDecoder[FiniteDuration] =
86+
JsonDecoder.long.map(_.seconds)
87+
88+
private[ziojson] implicit val instantDecoder: JsonDecoder[Instant] =
89+
JsonDecoder.long.map(Instant.ofEpochSecond)
90+
91+
private[ziojson] implicit val scopeDecoder: JsonDecoder[Scope] =
92+
JsonDecoder.string.mapOrFail { value =>
93+
Scope.from(value).left.map(identity)
94+
}
95+
96+
private[ziojson] implicit val optionScopeDecoder: JsonDecoder[Option[Scope]] =
97+
JsonDecoder.option[String].mapOrFail {
98+
case None | Some("") => Right(None)
99+
case Some(value) => Scope.from(value).map(Some(_)).left.map(identity)
100+
}
101+
102+
private[ziojson] implicit val audienceDecoder: JsonDecoder[Audience] =
103+
JsonDecoder
104+
.string
105+
.map(StringAudience(_))
106+
.orElse(
107+
JsonDecoder.list[String].map(seq => SeqAudience(seq))
108+
)
109+
110+
private[ziojson] implicit val codecConfig: JsonCodecConfiguration =
111+
JsonCodecConfiguration(fieldNameMapping = SnakeCase)
112+
113+
private[ziojson] implicit val tokenUserDetailsDecoder: JsonDecoder[TokenUserDetails] =
114+
DeriveJsonDecoder.gen[TokenUserDetails]
115+
116+
private[ziojson] val oAuth2TokenResponseDecoderImpl: JsonDecoder[OAuth2TokenResponse] =
117+
DeriveJsonDecoder.gen[OAuth2TokenResponse]
118+
119+
private[ziojson] val extendedOAuth2TokenResponseDecoderImpl: JsonDecoder[ExtendedOAuth2TokenResponse] =
120+
DeriveJsonDecoder.gen[ExtendedOAuth2TokenResponse]
121+
122+
private[ziojson] val tokenIntrospectionResponseDecoderImpl: JsonDecoder[TokenIntrospectionResponse] =
123+
DeriveJsonDecoder.gen[TokenIntrospectionResponse]
124+
125+
private[ziojson] val refreshTokenResponseDecoderImpl: JsonDecoder[RefreshTokenResponse] =
126+
DeriveJsonDecoder.gen[RefreshTokenResponse]
127+
128+
// Raw case classes for special handling
129+
130+
// Using Raw copy because the original UserInfo has list fields with default values (Nil)
131+
// that need special handling to map Option[List[String]] -> List[String]
132+
@jsonMemberNames(SnakeCase)
133+
private final case class UserInfoRaw(
134+
sub: Option[String],
135+
name: Option[String],
136+
givenName: Option[String],
137+
familyName: Option[String],
138+
jobTitle: Option[String],
139+
domain: Option[String],
140+
preferredUsername: Option[String],
141+
email: Option[String],
142+
emailVerified: Option[Boolean],
143+
locale: Option[String],
144+
sites: Option[List[String]],
145+
banners: Option[List[String]],
146+
regions: Option[List[String]],
147+
fulfillmentContexts: Option[List[String]]
148+
)
149+
150+
private val userInfoRawDecoder: JsonDecoder[UserInfoRaw] =
151+
DeriveJsonDecoder.gen[UserInfoRaw]
152+
153+
// Using Raw copy because we need to validate tokenType = "Bearer"
154+
@jsonMemberNames(SnakeCase)
155+
private final case class AccessTokenResponseRaw(
156+
accessToken: Secret[String],
157+
domain: Option[String],
158+
expiresIn: FiniteDuration,
159+
scope: Option[Scope],
160+
tokenType: String
161+
)
162+
163+
private val accessTokenResponseRawDecoder: JsonDecoder[AccessTokenResponseRaw] =
164+
DeriveJsonDecoder.gen[AccessTokenResponseRaw]
165+
166+
// Using Raw copy because we need custom construction via fromErrorTypeAndDescription
167+
@jsonMemberNames(SnakeCase)
168+
private final case class OAuth2ErrorRaw(
169+
error: String,
170+
errorDescription: Option[String]
171+
)
172+
173+
private val oAuth2ErrorRawDecoder: JsonDecoder[OAuth2ErrorRaw] =
174+
DeriveJsonDecoder.gen[OAuth2ErrorRaw]
175+
176+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package org.polyvariant.sttp.oauth2.json.ziojson
2+
3+
object instances extends ZioJsonDecoders
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package org.polyvariant.sttp.oauth2.json.ziojson
2+
3+
import org.polyvariant.sttp.oauth2.json.JsonSpec
4+
import org.polyvariant.sttp.oauth2.json.ziojson.instances._
5+
import org.polyvariant.sttp.oauth2.json.JsonDecoder
6+
import org.polyvariant.sttp.oauth2.Introspection.TokenIntrospectionResponse
7+
import org.polyvariant.sttp.oauth2.common._
8+
import org.polyvariant.sttp.oauth2.ClientCredentialsToken
9+
import org.polyvariant.sttp.oauth2.ExtendedOAuth2TokenResponse
10+
import org.polyvariant.sttp.oauth2.RefreshTokenResponse
11+
import org.polyvariant.sttp.oauth2.UserInfo
12+
13+
class ZioJsonSpec extends JsonSpec {
14+
15+
protected implicit def tokenIntrospectionResponseJsonDecoder: JsonDecoder[TokenIntrospectionResponse] = jsonDecoder
16+
17+
protected implicit def oAuth2ErrorJsonDecoder: JsonDecoder[Error.OAuth2Error] = jsonDecoder
18+
19+
protected implicit def extendedOAuth2TokenResponseJsonDecoder: JsonDecoder[ExtendedOAuth2TokenResponse] = jsonDecoder
20+
21+
protected implicit def refreshTokenResponseJsonDecoder: JsonDecoder[RefreshTokenResponse] = jsonDecoder
22+
23+
protected implicit def userInfoJsonDecoder: JsonDecoder[UserInfo] = jsonDecoder
24+
25+
protected implicit def accessTokenResponseJsonDecoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse] = jsonDecoder
26+
27+
}

0 commit comments

Comments
 (0)