@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/docs/facts.json +5 -5
- package/manifest.json +52 -31
- package/package.json +1 -1
- package/pipeline/skills/.skill-manifest.json +36 -20
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
# Retrofit Networking -- Reference Patterns
|
|
2
|
+
|
|
3
|
+
Full code for the patterns summarized in `SKILL.md`. Load the section you need
|
|
4
|
+
when implementing that part of the networking layer.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [API Interface](#api-interface)
|
|
9
|
+
- [Retrofit Setup with Hilt](#retrofit-setup-with-hilt)
|
|
10
|
+
- [OkHttp Interceptors](#okhttp-interceptors)
|
|
11
|
+
- [Error Handling](#error-handling)
|
|
12
|
+
- [Network-Bound Resource](#network-bound-resource)
|
|
13
|
+
- [Multipart Uploads](#multipart-uploads)
|
|
14
|
+
- [WebSocket Support](#websocket-support)
|
|
15
|
+
- [Envelope to Typed Exception](#envelope-to-typed-exception)
|
|
16
|
+
- [Unknown-Tolerant Enum Serialization](#unknown-tolerant-enum-serialization)
|
|
17
|
+
- [Client Composition and Environment Config](#client-composition-and-environment-config)
|
|
18
|
+
|
|
19
|
+
## API Interface
|
|
20
|
+
|
|
21
|
+
Define endpoints as suspend functions. Retrofit handles coroutine integration.
|
|
22
|
+
|
|
23
|
+
```kotlin
|
|
24
|
+
interface FlightApiService {
|
|
25
|
+
|
|
26
|
+
@GET("flights")
|
|
27
|
+
suspend fun searchFlights(
|
|
28
|
+
@Query("origin") origin: String,
|
|
29
|
+
@Query("destination") destination: String,
|
|
30
|
+
@Query("date") date: String,
|
|
31
|
+
): List<FlightDto>
|
|
32
|
+
|
|
33
|
+
@GET("flights/{id}")
|
|
34
|
+
suspend fun getFlightDetail(
|
|
35
|
+
@Path("id") flightId: String,
|
|
36
|
+
): FlightDetailDto
|
|
37
|
+
|
|
38
|
+
@POST("bookings")
|
|
39
|
+
suspend fun createBooking(
|
|
40
|
+
@Body request: BookingRequest,
|
|
41
|
+
): BookingResponse
|
|
42
|
+
|
|
43
|
+
@PUT("bookings/{id}")
|
|
44
|
+
suspend fun updateBooking(
|
|
45
|
+
@Path("id") bookingId: String,
|
|
46
|
+
@Body request: UpdateBookingRequest,
|
|
47
|
+
): BookingResponse
|
|
48
|
+
|
|
49
|
+
@DELETE("bookings/{id}")
|
|
50
|
+
suspend fun cancelBooking(
|
|
51
|
+
@Path("id") bookingId: String,
|
|
52
|
+
): Response<Unit>
|
|
53
|
+
|
|
54
|
+
@GET("flights")
|
|
55
|
+
suspend fun searchFlightsWithHeaders(
|
|
56
|
+
@Query("origin") origin: String,
|
|
57
|
+
@Header("Accept-Language") language: String = "en",
|
|
58
|
+
): List<FlightDto>
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### DTO Classes
|
|
63
|
+
|
|
64
|
+
```kotlin
|
|
65
|
+
@Serializable
|
|
66
|
+
data class FlightDto(
|
|
67
|
+
val id: String,
|
|
68
|
+
val origin: AirportDto,
|
|
69
|
+
val destination: AirportDto,
|
|
70
|
+
@SerialName("departure_time")
|
|
71
|
+
val departureTime: String,
|
|
72
|
+
@SerialName("arrival_time")
|
|
73
|
+
val arrivalTime: String,
|
|
74
|
+
val price: PriceDto,
|
|
75
|
+
val status: String,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@Serializable
|
|
79
|
+
data class AirportDto(
|
|
80
|
+
val code: String,
|
|
81
|
+
val name: String,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@Serializable
|
|
85
|
+
data class PriceDto(
|
|
86
|
+
val amount: Double,
|
|
87
|
+
val currency: String,
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Retrofit Setup with Hilt
|
|
92
|
+
|
|
93
|
+
```kotlin
|
|
94
|
+
@Module
|
|
95
|
+
@InstallIn(SingletonComponent::class)
|
|
96
|
+
object NetworkModule {
|
|
97
|
+
|
|
98
|
+
@Provides
|
|
99
|
+
@Singleton
|
|
100
|
+
fun provideJson(): Json = Json {
|
|
101
|
+
ignoreUnknownKeys = true
|
|
102
|
+
coerceInputValues = true
|
|
103
|
+
encodeDefaults = true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Provides
|
|
107
|
+
@Singleton
|
|
108
|
+
fun provideOkHttpClient(
|
|
109
|
+
authInterceptor: AuthInterceptor,
|
|
110
|
+
loggingInterceptor: HttpLoggingInterceptor,
|
|
111
|
+
): OkHttpClient = OkHttpClient.Builder()
|
|
112
|
+
.connectTimeout(30, TimeUnit.SECONDS)
|
|
113
|
+
.readTimeout(30, TimeUnit.SECONDS)
|
|
114
|
+
.writeTimeout(30, TimeUnit.SECONDS)
|
|
115
|
+
.addInterceptor(authInterceptor)
|
|
116
|
+
.addInterceptor(loggingInterceptor)
|
|
117
|
+
.build()
|
|
118
|
+
|
|
119
|
+
@Provides
|
|
120
|
+
@Singleton
|
|
121
|
+
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
|
|
122
|
+
HttpLoggingInterceptor().apply {
|
|
123
|
+
level = if (BuildConfig.DEBUG) {
|
|
124
|
+
HttpLoggingInterceptor.Level.BODY
|
|
125
|
+
} else {
|
|
126
|
+
HttpLoggingInterceptor.Level.NONE
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@Provides
|
|
131
|
+
@Singleton
|
|
132
|
+
fun provideRetrofit(
|
|
133
|
+
client: OkHttpClient,
|
|
134
|
+
json: Json,
|
|
135
|
+
): Retrofit = Retrofit.Builder()
|
|
136
|
+
.baseUrl(BuildConfig.BASE_URL)
|
|
137
|
+
.client(client)
|
|
138
|
+
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
|
139
|
+
.build()
|
|
140
|
+
|
|
141
|
+
@Provides
|
|
142
|
+
@Singleton
|
|
143
|
+
fun provideFlightApiService(retrofit: Retrofit): FlightApiService =
|
|
144
|
+
retrofit.create(FlightApiService::class.java)
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## OkHttp Interceptors
|
|
149
|
+
|
|
150
|
+
### Auth Interceptor (Token Injection)
|
|
151
|
+
|
|
152
|
+
```kotlin
|
|
153
|
+
class AuthInterceptor @Inject constructor(
|
|
154
|
+
private val tokenProvider: TokenProvider,
|
|
155
|
+
) : Interceptor {
|
|
156
|
+
|
|
157
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
158
|
+
val token = tokenProvider.getAccessToken()
|
|
159
|
+
val request = chain.request().newBuilder().apply {
|
|
160
|
+
token?.let { addHeader("Authorization", "Bearer $it") }
|
|
161
|
+
}.build()
|
|
162
|
+
|
|
163
|
+
val response = chain.proceed(request)
|
|
164
|
+
|
|
165
|
+
// Handle 401: refresh token and retry
|
|
166
|
+
if (response.code == 401) {
|
|
167
|
+
response.close()
|
|
168
|
+
val newToken = tokenProvider.refreshToken()
|
|
169
|
+
?: return response
|
|
170
|
+
|
|
171
|
+
val retryRequest = chain.request().newBuilder()
|
|
172
|
+
.header("Authorization", "Bearer $newToken")
|
|
173
|
+
.build()
|
|
174
|
+
|
|
175
|
+
return chain.proceed(retryRequest)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return response
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Retry Interceptor
|
|
184
|
+
|
|
185
|
+
```kotlin
|
|
186
|
+
class RetryInterceptor(
|
|
187
|
+
private val maxRetries: Int = 3,
|
|
188
|
+
private val initialDelayMs: Long = 1_000,
|
|
189
|
+
) : Interceptor {
|
|
190
|
+
|
|
191
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
192
|
+
var lastException: IOException? = null
|
|
193
|
+
var delay = initialDelayMs
|
|
194
|
+
|
|
195
|
+
repeat(maxRetries) { attempt ->
|
|
196
|
+
try {
|
|
197
|
+
val response = chain.proceed(chain.request())
|
|
198
|
+
if (response.isSuccessful || response.code !in 500..599) {
|
|
199
|
+
return response
|
|
200
|
+
}
|
|
201
|
+
response.close()
|
|
202
|
+
} catch (e: IOException) {
|
|
203
|
+
lastException = e
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (attempt < maxRetries - 1) {
|
|
207
|
+
Thread.sleep(delay)
|
|
208
|
+
delay *= 2 // Exponential backoff
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw lastException ?: IOException("Request failed after $maxRetries retries")
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Cache Interceptor
|
|
218
|
+
|
|
219
|
+
```kotlin
|
|
220
|
+
class CacheInterceptor : Interceptor {
|
|
221
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
222
|
+
val response = chain.proceed(chain.request())
|
|
223
|
+
val cacheControl = CacheControl.Builder()
|
|
224
|
+
.maxAge(5, TimeUnit.MINUTES)
|
|
225
|
+
.build()
|
|
226
|
+
|
|
227
|
+
return response.newBuilder()
|
|
228
|
+
.header("Cache-Control", cacheControl.toString())
|
|
229
|
+
.removeHeader("Pragma")
|
|
230
|
+
.build()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Add cache to OkHttpClient
|
|
235
|
+
val cacheDir = File(context.cacheDir, "http_cache")
|
|
236
|
+
val cache = Cache(cacheDir, 10L * 1024 * 1024) // 10 MB
|
|
237
|
+
|
|
238
|
+
OkHttpClient.Builder()
|
|
239
|
+
.cache(cache)
|
|
240
|
+
.addNetworkInterceptor(CacheInterceptor())
|
|
241
|
+
.build()
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Error Handling
|
|
245
|
+
|
|
246
|
+
### Sealed Result Type
|
|
247
|
+
|
|
248
|
+
```kotlin
|
|
249
|
+
sealed interface NetworkResult<out T> {
|
|
250
|
+
data class Success<T>(val data: T) : NetworkResult<T>
|
|
251
|
+
data class Error(val code: Int, val message: String) : NetworkResult<Nothing>
|
|
252
|
+
data class Exception(val throwable: Throwable) : NetworkResult<Nothing>
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Safe API Call Wrapper
|
|
257
|
+
|
|
258
|
+
```kotlin
|
|
259
|
+
suspend fun <T> safeApiCall(
|
|
260
|
+
apiCall: suspend () -> T,
|
|
261
|
+
): NetworkResult<T> = try {
|
|
262
|
+
NetworkResult.Success(apiCall())
|
|
263
|
+
} catch (e: HttpException) {
|
|
264
|
+
val errorBody = e.response()?.errorBody()?.string()
|
|
265
|
+
NetworkResult.Error(
|
|
266
|
+
code = e.code(),
|
|
267
|
+
message = errorBody ?: e.message(),
|
|
268
|
+
)
|
|
269
|
+
} catch (e: IOException) {
|
|
270
|
+
NetworkResult.Exception(e)
|
|
271
|
+
} catch (e: CancellationException) {
|
|
272
|
+
throw e // Never swallow cancellation
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Usage in Repository
|
|
277
|
+
|
|
278
|
+
```kotlin
|
|
279
|
+
class FlightRepositoryImpl @Inject constructor(
|
|
280
|
+
private val api: FlightApiService,
|
|
281
|
+
private val mapper: FlightMapper,
|
|
282
|
+
) : FlightRepository {
|
|
283
|
+
|
|
284
|
+
override suspend fun searchFlights(
|
|
285
|
+
origin: String,
|
|
286
|
+
destination: String,
|
|
287
|
+
date: LocalDate,
|
|
288
|
+
): NetworkResult<List<Flight>> = safeApiCall {
|
|
289
|
+
api.searchFlights(origin, destination, date.toString())
|
|
290
|
+
.map(mapper::toDomain)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### ViewModel Consumption
|
|
296
|
+
|
|
297
|
+
```kotlin
|
|
298
|
+
@HiltViewModel
|
|
299
|
+
class SearchViewModel @Inject constructor(
|
|
300
|
+
private val repository: FlightRepository,
|
|
301
|
+
) : ViewModel() {
|
|
302
|
+
|
|
303
|
+
private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
|
|
304
|
+
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
|
|
305
|
+
|
|
306
|
+
fun search(origin: String, destination: String, date: LocalDate) {
|
|
307
|
+
viewModelScope.launch {
|
|
308
|
+
_uiState.value = SearchUiState.Loading
|
|
309
|
+
|
|
310
|
+
_uiState.value = when (val result = repository.searchFlights(origin, destination, date)) {
|
|
311
|
+
is NetworkResult.Success -> SearchUiState.Success(result.data)
|
|
312
|
+
is NetworkResult.Error -> SearchUiState.Error("Server error: ${result.message}")
|
|
313
|
+
is NetworkResult.Exception -> SearchUiState.Error(
|
|
314
|
+
result.throwable.localizedMessage ?: "Network error"
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Network-Bound Resource
|
|
323
|
+
|
|
324
|
+
Pattern for showing cached data while fetching fresh data from the network.
|
|
325
|
+
|
|
326
|
+
```kotlin
|
|
327
|
+
inline fun <ResultType, RequestType> networkBoundResource(
|
|
328
|
+
crossinline query: () -> Flow<ResultType>,
|
|
329
|
+
crossinline fetch: suspend () -> RequestType,
|
|
330
|
+
crossinline saveFetchResult: suspend (RequestType) -> Unit,
|
|
331
|
+
crossinline shouldFetch: (ResultType) -> Boolean = { true },
|
|
332
|
+
): Flow<Resource<ResultType>> = flow {
|
|
333
|
+
emit(Resource.Loading())
|
|
334
|
+
|
|
335
|
+
val data = query().first()
|
|
336
|
+
|
|
337
|
+
val flow = if (shouldFetch(data)) {
|
|
338
|
+
emit(Resource.Loading(data))
|
|
339
|
+
try {
|
|
340
|
+
val fetchedData = fetch()
|
|
341
|
+
saveFetchResult(fetchedData)
|
|
342
|
+
query().map { Resource.Success(it) }
|
|
343
|
+
} catch (throwable: Throwable) {
|
|
344
|
+
if (throwable is CancellationException) throw throwable
|
|
345
|
+
query().map { Resource.Error(throwable, it) }
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
query().map { Resource.Success(it) }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
emitAll(flow)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
sealed class Resource<T>(
|
|
355
|
+
val data: T? = null,
|
|
356
|
+
val error: Throwable? = null,
|
|
357
|
+
) {
|
|
358
|
+
class Success<T>(data: T) : Resource<T>(data)
|
|
359
|
+
class Loading<T>(data: T? = null) : Resource<T>(data)
|
|
360
|
+
class Error<T>(throwable: Throwable, data: T? = null) : Resource<T>(data, throwable)
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Usage
|
|
365
|
+
|
|
366
|
+
```kotlin
|
|
367
|
+
override fun getFlights(origin: String, destination: String): Flow<Resource<List<Flight>>> =
|
|
368
|
+
networkBoundResource(
|
|
369
|
+
query = {
|
|
370
|
+
dao.getFlights(origin, destination)
|
|
371
|
+
.map { entities -> entities.map(mapper::toDomain) }
|
|
372
|
+
},
|
|
373
|
+
fetch = { api.searchFlights(origin, destination, today()) },
|
|
374
|
+
saveFetchResult = { dtos ->
|
|
375
|
+
dao.upsertFlights(dtos.map(mapper::toEntity))
|
|
376
|
+
},
|
|
377
|
+
shouldFetch = { cachedFlights ->
|
|
378
|
+
cachedFlights.isEmpty() || cacheExpired()
|
|
379
|
+
},
|
|
380
|
+
)
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## Multipart Uploads
|
|
384
|
+
|
|
385
|
+
```kotlin
|
|
386
|
+
interface FileApiService {
|
|
387
|
+
|
|
388
|
+
@Multipart
|
|
389
|
+
@POST("documents/upload")
|
|
390
|
+
suspend fun uploadDocument(
|
|
391
|
+
@Part file: MultipartBody.Part,
|
|
392
|
+
@Part("description") description: RequestBody,
|
|
393
|
+
): UploadResponse
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Usage
|
|
397
|
+
suspend fun uploadFile(context: Context, uri: Uri, description: String) {
|
|
398
|
+
val contentResolver = context.contentResolver
|
|
399
|
+
val inputStream = contentResolver.openInputStream(uri) ?: return
|
|
400
|
+
val fileName = getFileName(context, uri)
|
|
401
|
+
|
|
402
|
+
val requestBody = inputStream.readBytes()
|
|
403
|
+
.toRequestBody("application/octet-stream".toMediaType())
|
|
404
|
+
|
|
405
|
+
val part = MultipartBody.Part.createFormData("file", fileName, requestBody)
|
|
406
|
+
val descriptionBody = description.toRequestBody("text/plain".toMediaType())
|
|
407
|
+
|
|
408
|
+
api.uploadDocument(part, descriptionBody)
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## WebSocket Support
|
|
413
|
+
|
|
414
|
+
```kotlin
|
|
415
|
+
class FlightUpdatesWebSocket @Inject constructor(
|
|
416
|
+
private val client: OkHttpClient,
|
|
417
|
+
) {
|
|
418
|
+
private var webSocket: WebSocket? = null
|
|
419
|
+
|
|
420
|
+
fun connect(flightId: String): Flow<FlightUpdate> = callbackFlow {
|
|
421
|
+
val request = Request.Builder()
|
|
422
|
+
.url("wss://api.example.com/flights/$flightId/updates")
|
|
423
|
+
.build()
|
|
424
|
+
|
|
425
|
+
val listener = object : WebSocketListener() {
|
|
426
|
+
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
427
|
+
val update = Json.decodeFromString<FlightUpdate>(text)
|
|
428
|
+
trySend(update)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
432
|
+
close(t)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
436
|
+
channel.close()
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
webSocket = client.newWebSocket(request, listener)
|
|
441
|
+
|
|
442
|
+
awaitClose {
|
|
443
|
+
webSocket?.close(1000, "Client closed")
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fun disconnect() {
|
|
448
|
+
webSocket?.close(1000, "Client closed")
|
|
449
|
+
webSocket = null
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## Envelope to Typed Exception
|
|
455
|
+
|
|
456
|
+
The backend wraps every payload in a `success` envelope. A delegating
|
|
457
|
+
`Converter.Factory` unwraps it and turns a business failure into a typed
|
|
458
|
+
`ApiException`, so `success == false` surfaces through the same catch path as a
|
|
459
|
+
transport failure instead of forcing every call site to inspect the body.
|
|
460
|
+
|
|
461
|
+
```kotlin
|
|
462
|
+
@Serializable
|
|
463
|
+
data class ApiEnvelope<T>(
|
|
464
|
+
val success: Boolean,
|
|
465
|
+
val data: T? = null,
|
|
466
|
+
@SerialName("error_code") val errorCode: Int? = null,
|
|
467
|
+
val message: String? = null,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
class ApiException(
|
|
471
|
+
val code: Int,
|
|
472
|
+
override val message: String,
|
|
473
|
+
) : IOException(message)
|
|
474
|
+
|
|
475
|
+
private fun parameterized(raw: Type, arg: Type): ParameterizedType =
|
|
476
|
+
object : ParameterizedType {
|
|
477
|
+
override fun getRawType(): Type = raw
|
|
478
|
+
override fun getActualTypeArguments(): Array<Type> = arrayOf(arg)
|
|
479
|
+
override fun getOwnerType(): Type? = null
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
class EnvelopeUnwrapFactory(
|
|
483
|
+
private val delegate: Converter.Factory,
|
|
484
|
+
) : Converter.Factory() {
|
|
485
|
+
override fun responseBodyConverter(
|
|
486
|
+
type: Type,
|
|
487
|
+
annotations: Array<out Annotation>,
|
|
488
|
+
retrofit: Retrofit,
|
|
489
|
+
): Converter<ResponseBody, *>? {
|
|
490
|
+
val envelopeType = parameterized(ApiEnvelope::class.java, type)
|
|
491
|
+
val inner = delegate.responseBodyConverter(envelopeType, annotations, retrofit)
|
|
492
|
+
?: return null
|
|
493
|
+
return Converter<ResponseBody, Any?> { body ->
|
|
494
|
+
val envelope = inner.convert(body) as ApiEnvelope<*>
|
|
495
|
+
if (!envelope.success) {
|
|
496
|
+
throw ApiException(envelope.errorCode ?: -1, envelope.message.orEmpty())
|
|
497
|
+
}
|
|
498
|
+
envelope.data
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Register it in front of the serialization converter:
|
|
505
|
+
|
|
506
|
+
```kotlin
|
|
507
|
+
Retrofit.Builder()
|
|
508
|
+
.baseUrl(config.baseUrl)
|
|
509
|
+
.client(client)
|
|
510
|
+
.addConverterFactory(EnvelopeUnwrapFactory(json.asConverterFactory(contentType)))
|
|
511
|
+
.build()
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Central throwable-to-sealed mapper, called from `safeApiCall`:
|
|
515
|
+
|
|
516
|
+
```kotlin
|
|
517
|
+
fun Throwable.toNetworkError(): NetworkResult.Error = when (this) {
|
|
518
|
+
is ApiException -> NetworkResult.Error(code, message)
|
|
519
|
+
is HttpException -> NetworkResult.Error(code(), message())
|
|
520
|
+
is SocketTimeoutException -> NetworkResult.Error(-1, "Request timed out")
|
|
521
|
+
is IOException -> NetworkResult.Error(-1, "No network connection")
|
|
522
|
+
else -> NetworkResult.Error(-1, message ?: "Unexpected error")
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
suspend fun <T> safeApiCall(block: suspend () -> T): NetworkResult<T> =
|
|
526
|
+
try {
|
|
527
|
+
NetworkResult.Success(block())
|
|
528
|
+
} catch (e: CancellationException) {
|
|
529
|
+
throw e
|
|
530
|
+
} catch (e: Throwable) {
|
|
531
|
+
e.toNetworkError()
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
## Unknown-Tolerant Enum Serialization
|
|
536
|
+
|
|
537
|
+
`ignoreUnknownKeys` tolerates unknown object keys, not unknown enum values: a new
|
|
538
|
+
server code fails the whole response. A base `KSerializer` maps any unrecognized
|
|
539
|
+
value to a declared default so a single field degrades instead of the payload.
|
|
540
|
+
|
|
541
|
+
```kotlin
|
|
542
|
+
interface DefaultedEnum {
|
|
543
|
+
val serialName: String
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
abstract class UnknownTolerantEnumSerializer<T>(
|
|
547
|
+
serialName: String,
|
|
548
|
+
private val values: Array<T>,
|
|
549
|
+
private val default: T,
|
|
550
|
+
) : KSerializer<T> where T : Enum<T>, T : DefaultedEnum {
|
|
551
|
+
override val descriptor: SerialDescriptor =
|
|
552
|
+
PrimitiveSerialDescriptor(serialName, PrimitiveKind.STRING)
|
|
553
|
+
|
|
554
|
+
override fun serialize(encoder: Encoder, value: T) = encoder.encodeString(value.serialName)
|
|
555
|
+
|
|
556
|
+
override fun deserialize(decoder: Decoder): T {
|
|
557
|
+
val raw = decoder.decodeString()
|
|
558
|
+
return values.firstOrNull { it.serialName == raw } ?: default
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Usage:
|
|
564
|
+
|
|
565
|
+
```kotlin
|
|
566
|
+
@Serializable(with = CabinClassSerializer::class)
|
|
567
|
+
enum class CabinClass(override val serialName: String) : DefaultedEnum {
|
|
568
|
+
ECONOMY("ECONOMY"),
|
|
569
|
+
BUSINESS("BUSINESS"),
|
|
570
|
+
UNKNOWN("UNKNOWN"),
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
object CabinClassSerializer : UnknownTolerantEnumSerializer<CabinClass>(
|
|
574
|
+
"CabinClass", CabinClass.entries.toTypedArray(), CabinClass.UNKNOWN,
|
|
575
|
+
)
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
## Client Composition and Environment Config
|
|
579
|
+
|
|
580
|
+
Derive every specialized client from one base with `newBuilder()`. An image
|
|
581
|
+
pipeline must not send auth headers, so it clears inherited interceptors:
|
|
582
|
+
|
|
583
|
+
```kotlin
|
|
584
|
+
@Provides
|
|
585
|
+
@Singleton
|
|
586
|
+
@ImageClient
|
|
587
|
+
fun provideImageClient(
|
|
588
|
+
@BaseClient base: OkHttpClient,
|
|
589
|
+
cache: Cache,
|
|
590
|
+
): OkHttpClient = base.newBuilder()
|
|
591
|
+
.apply { interceptors().clear() }
|
|
592
|
+
.cache(cache)
|
|
593
|
+
.build()
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Resolve the base URL and pin hashes per flavor from build config, with a runtime
|
|
597
|
+
override allowed only outside production (QA environment switching):
|
|
598
|
+
|
|
599
|
+
```kotlin
|
|
600
|
+
class NetworkConfig(
|
|
601
|
+
private val settings: EnvironmentSettings,
|
|
602
|
+
) {
|
|
603
|
+
val baseUrl: String
|
|
604
|
+
get() = if (BuildConfig.IS_PRODUCTION) {
|
|
605
|
+
BuildConfig.BASE_URL
|
|
606
|
+
} else {
|
|
607
|
+
settings.overrideBaseUrl ?: BuildConfig.BASE_URL
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
fun certificatePinner(): CertificatePinner {
|
|
611
|
+
val host = baseUrl.toHttpUrl().host
|
|
612
|
+
return CertificatePinner.Builder()
|
|
613
|
+
.apply { BuildConfig.PIN_HASHES.forEach { add(host, it) } }
|
|
614
|
+
.build()
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
`BASE_URL` and `PIN_HASHES` are `buildConfigField`s per product flavor;
|
|
620
|
+
`IS_PRODUCTION` gates the override so QA builds can retarget without a rebuild.
|
|
621
|
+
|
|
622
|
+
Deadlock-safe suspend-in-interceptor bridge. Plain `runBlocking { }` resumes on
|
|
623
|
+
the interceptor's own thread and can deadlock when the token fetch reuses the
|
|
624
|
+
same dispatcher. Pin the suspend work to a dedicated single-thread dispatcher:
|
|
625
|
+
|
|
626
|
+
```kotlin
|
|
627
|
+
class TokenInterceptor @Inject constructor(
|
|
628
|
+
private val tokenProvider: TokenProvider,
|
|
629
|
+
) : Interceptor {
|
|
630
|
+
private val tokenDispatcher = Dispatchers.IO.limitedParallelism(1)
|
|
631
|
+
|
|
632
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
633
|
+
val token = runBlocking(tokenDispatcher) { tokenProvider.currentToken() }
|
|
634
|
+
val request = chain.request().newBuilder()
|
|
635
|
+
.header("Authorization", "Bearer $token")
|
|
636
|
+
.build()
|
|
637
|
+
return chain.proceed(request)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
```
|