@rivi-software/radiotools-sdk-generator 0.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/dist/cli.d.ts +2 -0
- package/dist/cli.js +44 -0
- package/dist/engine.d.ts +3 -0
- package/dist/engine.js +60 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/openapi/zod-components.d.ts +13 -0
- package/dist/openapi/zod-components.js +47 -0
- package/dist/shared/naming.d.ts +2 -0
- package/dist/shared/naming.js +6 -0
- package/dist/shared/reads.d.ts +5 -0
- package/dist/shared/reads.js +14 -0
- package/dist/targets/csharp.d.ts +2 -0
- package/dist/targets/csharp.js +137 -0
- package/dist/targets/go.d.ts +2 -0
- package/dist/targets/go.js +185 -0
- package/dist/targets/kotlin.d.ts +2 -0
- package/dist/targets/kotlin.js +180 -0
- package/dist/targets/next.d.ts +2 -0
- package/dist/targets/next.js +80 -0
- package/dist/targets/openapi.d.ts +2 -0
- package/dist/targets/openapi.js +223 -0
- package/dist/targets/php.d.ts +2 -0
- package/dist/targets/php.js +165 -0
- package/dist/targets/python.d.ts +2 -0
- package/dist/targets/python.js +124 -0
- package/dist/targets/react.d.ts +2 -0
- package/dist/targets/react.js +82 -0
- package/dist/targets/ruby.d.ts +2 -0
- package/dist/targets/ruby.js +122 -0
- package/dist/targets/svelte.d.ts +2 -0
- package/dist/targets/svelte.js +76 -0
- package/dist/targets/swift.d.ts +2 -0
- package/dist/targets/swift.js +179 -0
- package/dist/targets/vue.d.ts +2 -0
- package/dist/targets/vue.js +67 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.js +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { listenerSdkManifest } from "@rivi-software/radiotools-contract";
|
|
2
|
+
function renderKotlinClient() {
|
|
3
|
+
const manifest = listenerSdkManifest;
|
|
4
|
+
return `// Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand.
|
|
5
|
+
package com.radiotools.sdk
|
|
6
|
+
|
|
7
|
+
import kotlinx.serialization.json.Json
|
|
8
|
+
import kotlinx.serialization.json.JsonObject
|
|
9
|
+
import kotlinx.serialization.json.buildJsonObject
|
|
10
|
+
import kotlinx.serialization.json.put
|
|
11
|
+
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
12
|
+
import okhttp3.OkHttpClient
|
|
13
|
+
import okhttp3.Request
|
|
14
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
15
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
16
|
+
import java.io.IOException
|
|
17
|
+
import java.util.concurrent.TimeUnit
|
|
18
|
+
|
|
19
|
+
class StationClient private constructor(
|
|
20
|
+
private val baseUrl: String,
|
|
21
|
+
private val stationSlug: String,
|
|
22
|
+
private val apiToken: String?,
|
|
23
|
+
private val ingestToken: String?,
|
|
24
|
+
private val http: OkHttpClient,
|
|
25
|
+
private val json: Json,
|
|
26
|
+
) {
|
|
27
|
+
companion object {
|
|
28
|
+
fun create(
|
|
29
|
+
baseUrl: String,
|
|
30
|
+
stationSlug: String,
|
|
31
|
+
apiToken: String? = null,
|
|
32
|
+
ingestToken: String? = null,
|
|
33
|
+
http: OkHttpClient? = null,
|
|
34
|
+
): StationClient {
|
|
35
|
+
require(!apiToken.isNullOrBlank() || !ingestToken.isNullOrBlank()) {
|
|
36
|
+
"apiToken or ingestToken is required"
|
|
37
|
+
}
|
|
38
|
+
val client =
|
|
39
|
+
http
|
|
40
|
+
?: OkHttpClient.Builder()
|
|
41
|
+
.connectTimeout(30, TimeUnit.SECONDS)
|
|
42
|
+
.readTimeout(30, TimeUnit.SECONDS)
|
|
43
|
+
.build()
|
|
44
|
+
return StationClient(
|
|
45
|
+
baseUrl.trimEnd('/'),
|
|
46
|
+
stationSlug,
|
|
47
|
+
apiToken?.trim()?.takeIf { it.isNotEmpty() },
|
|
48
|
+
ingestToken?.trim()?.takeIf { it.isNotEmpty() },
|
|
49
|
+
client,
|
|
50
|
+
Json { ignoreUnknownKeys = true },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Throws(IOException::class)
|
|
56
|
+
fun getStation(): JsonObject = getJson("/api/v1/stations/\${encode(stationSlug)}", ingest = false)
|
|
57
|
+
|
|
58
|
+
@Throws(IOException::class)
|
|
59
|
+
fun getNowPlaying(): JsonObject {
|
|
60
|
+
val station = getStation()
|
|
61
|
+
val href =
|
|
62
|
+
station["resources"]?.let { it as? JsonObject }?.get("nowPlaying")?.toString()?.trim('"')
|
|
63
|
+
?: error("nowPlaying resource unavailable")
|
|
64
|
+
return getJson(href, ingest = false)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Throws(IOException::class)
|
|
68
|
+
fun getWeather(): JsonObject? {
|
|
69
|
+
val href =
|
|
70
|
+
getStation()["resources"]?.let { it as? JsonObject }?.get("weather")?.toString()?.trim('"')
|
|
71
|
+
if (href.isNullOrEmpty()) return null
|
|
72
|
+
return getJson(href, ingest = false)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Throws(IOException::class)
|
|
76
|
+
fun getTraffic(): JsonObject? {
|
|
77
|
+
val href =
|
|
78
|
+
getStation()["resources"]?.let { it as? JsonObject }?.get("traffic")?.toString()?.trim('"')
|
|
79
|
+
if (href.isNullOrEmpty()) return null
|
|
80
|
+
return getJson(href, ingest = false)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Throws(IOException::class)
|
|
84
|
+
fun reportPlayback(observation: JsonObject, stationId: String? = null): JsonObject {
|
|
85
|
+
val query =
|
|
86
|
+
if (!stationId.isNullOrBlank()) {
|
|
87
|
+
"stationId=\${encode(stationId)}"
|
|
88
|
+
} else {
|
|
89
|
+
"stationSlug=\${encode(stationSlug)}"
|
|
90
|
+
}
|
|
91
|
+
val path = "${manifest.integrations.playback.path}?\$query"
|
|
92
|
+
return postJson(path, observation, ingest = true)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private fun getJson(path: String, ingest: Boolean): JsonObject {
|
|
96
|
+
val url = if (path.startsWith("http")) path else "\$baseUrl\$path"
|
|
97
|
+
val request =
|
|
98
|
+
Request.Builder()
|
|
99
|
+
.url(url.toHttpUrl())
|
|
100
|
+
.header("Authorization", bearer(ingest))
|
|
101
|
+
.get()
|
|
102
|
+
.build()
|
|
103
|
+
http.newCall(request).execute().use { response ->
|
|
104
|
+
val body = response.body?.string() ?: "{}"
|
|
105
|
+
if (!response.isSuccessful) error("GET \${'$'}url failed: \${response.code} \${'$'}body")
|
|
106
|
+
return json.decodeFromString(JsonObject.serializer(), body)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private fun postJson(path: String, body: JsonObject, ingest: Boolean): JsonObject {
|
|
111
|
+
val url = "\$baseUrl\$path".toHttpUrl()
|
|
112
|
+
val mediaType = "application/json; charset=utf-8".toMediaType()
|
|
113
|
+
val payload = json.encodeToString(JsonObject.serializer(), body)
|
|
114
|
+
val request =
|
|
115
|
+
Request.Builder()
|
|
116
|
+
.url(url)
|
|
117
|
+
.header("Authorization", bearer(ingest))
|
|
118
|
+
.post(payload.toRequestBody(mediaType))
|
|
119
|
+
.build()
|
|
120
|
+
http.newCall(request).execute().use { response ->
|
|
121
|
+
val text = response.body?.string() ?: "{}"
|
|
122
|
+
if (!response.isSuccessful) error("POST \${'$'}url failed: \${response.code} \${'$'}text")
|
|
123
|
+
return json.decodeFromString(JsonObject.serializer(), text)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun bearer(ingest: Boolean): String {
|
|
128
|
+
val token = if (ingest) ingestToken else apiToken
|
|
129
|
+
require(!token.isNullOrBlank()) { "Missing bearer token" }
|
|
130
|
+
return "Bearer \$token"
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fun encode(value: String): String =
|
|
134
|
+
java.net.URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20")
|
|
135
|
+
}
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
export const kotlinTarget = {
|
|
139
|
+
id: "kotlin",
|
|
140
|
+
description: "Kotlin/Android OkHttp client stubs (packages/sdk-kotlin)",
|
|
141
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-kotlin/src/main/kotlin/com/radiotools/sdk`,
|
|
142
|
+
generate: (repoRoot) => [
|
|
143
|
+
{ path: "StationClient.kt", contents: renderKotlinClient() },
|
|
144
|
+
{
|
|
145
|
+
path: `${repoRoot}/packages/sdk-kotlin/build.gradle.kts`,
|
|
146
|
+
contents: `plugins {
|
|
147
|
+
kotlin("jvm") version "2.1.0"
|
|
148
|
+
kotlin("plugin.serialization") version "2.1.0"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
group = "com.radiotools"
|
|
152
|
+
version = "0.2.0"
|
|
153
|
+
|
|
154
|
+
repositories {
|
|
155
|
+
mavenCentral()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
dependencies {
|
|
159
|
+
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
|
160
|
+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
kotlin {
|
|
164
|
+
jvmToolchain(17)
|
|
165
|
+
}
|
|
166
|
+
`,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
path: `${repoRoot}/packages/sdk-kotlin/README.md`,
|
|
170
|
+
contents: `# RadioTools Kotlin SDK
|
|
171
|
+
|
|
172
|
+
Generated Android/JVM REST stubs (OkHttp + kotlinx.serialization).
|
|
173
|
+
|
|
174
|
+
Regenerate: \`bun run --filter @rivi-software/radiotools-sdk-generator generate --target kotlin\`
|
|
175
|
+
|
|
176
|
+
OpenAPI models: see \`packages/openapi/openapi.json\` (from Zod \`sdkSchemas\`).
|
|
177
|
+
`,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ZERO_ARG_READ_METHODS } from "../shared/reads";
|
|
2
|
+
function renderNextServerHelpers() {
|
|
3
|
+
const cachedReads = ZERO_ARG_READ_METHODS.map((method) => {
|
|
4
|
+
const fnName = `fetch${method.charAt(0).toUpperCase()}${method.slice(1)}`;
|
|
5
|
+
return `export async function ${fnName}(client: StationClient) {
|
|
6
|
+
return client.${method}();
|
|
7
|
+
}`;
|
|
8
|
+
}).join("\n\n");
|
|
9
|
+
const routeHandlers = ZERO_ARG_READ_METHODS.map((method) => {
|
|
10
|
+
const suffix = method.replace(/^get/, "");
|
|
11
|
+
const exportName = `jsonResponse${suffix}`;
|
|
12
|
+
const routeName = suffix.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
13
|
+
return `/** App Router helper for \`/api/radiotools/${routeName}\` */
|
|
14
|
+
export async function ${exportName}(client: StationClient) {
|
|
15
|
+
const data = await client.${method}();
|
|
16
|
+
return NextResponse.json(data);
|
|
17
|
+
}`;
|
|
18
|
+
}).join("\n\n");
|
|
19
|
+
return `/** Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand. */
|
|
20
|
+
import "server-only";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
createStationClientFromEnv,
|
|
24
|
+
type CreateStationClientOptions,
|
|
25
|
+
type StationClient,
|
|
26
|
+
} from "@rivi-software/radiotools-sdk";
|
|
27
|
+
import { unstable_cache } from "next/cache";
|
|
28
|
+
import { NextResponse } from "next/server";
|
|
29
|
+
|
|
30
|
+
const DEFAULT_REVALIDATE_SECONDS = 30;
|
|
31
|
+
|
|
32
|
+
export function createRadioToolsServerClient(
|
|
33
|
+
overrides: Partial<CreateStationClientOptions> = {},
|
|
34
|
+
): StationClient {
|
|
35
|
+
return createStationClientFromEnv(undefined, overrides);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createCachedRadioToolsReader<T>(
|
|
39
|
+
cacheKey: string,
|
|
40
|
+
read: (client: StationClient) => Promise<T>,
|
|
41
|
+
revalidateSeconds: number = DEFAULT_REVALIDATE_SECONDS,
|
|
42
|
+
) {
|
|
43
|
+
return unstable_cache(async () => read(createRadioToolsServerClient()), [cacheKey], {
|
|
44
|
+
revalidate: revalidateSeconds,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
${cachedReads}
|
|
49
|
+
|
|
50
|
+
export async function fetchRecommendedPollIntervalMs(client: StationClient) {
|
|
51
|
+
return client.recommendedPollIntervalMs();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const cachedGetStation = createCachedRadioToolsReader("radiotools:station", (c) => c.getStation());
|
|
55
|
+
export const cachedGetNowPlaying = createCachedRadioToolsReader("radiotools:now-playing", (c) =>
|
|
56
|
+
c.getNowPlaying(),
|
|
57
|
+
);
|
|
58
|
+
export const cachedGetCurrentBroadcast = createCachedRadioToolsReader(
|
|
59
|
+
"radiotools:current-broadcast",
|
|
60
|
+
(c) => c.getCurrentBroadcast(),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
${routeHandlers}
|
|
64
|
+
|
|
65
|
+
export type { StationClient, CreateStationClientOptions };
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
export const nextTarget = {
|
|
69
|
+
id: "next",
|
|
70
|
+
description: "Next.js App Router server helpers (packages/sdk-next)",
|
|
71
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-next/src`,
|
|
72
|
+
generate: () => [
|
|
73
|
+
{ path: "server.generated.ts", contents: renderNextServerHelpers() },
|
|
74
|
+
{
|
|
75
|
+
path: "index.ts",
|
|
76
|
+
contents: `export * from "./server.generated";
|
|
77
|
+
`,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { listenerSdkManifest } from "@rivi-software/radiotools-contract";
|
|
2
|
+
import { buildZodOpenApiComponents, jsonSchemaRef, operationResponseSchemas, } from "../openapi/zod-components";
|
|
3
|
+
function errorResponseContent() {
|
|
4
|
+
return {
|
|
5
|
+
"application/json": {
|
|
6
|
+
schema: jsonSchemaRef("ErrorResponse"),
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function successResponse(operationId) {
|
|
11
|
+
const mapping = operationResponseSchemas[operationId];
|
|
12
|
+
if (!mapping) {
|
|
13
|
+
return { description: "Success" };
|
|
14
|
+
}
|
|
15
|
+
const schema = jsonSchemaRef(mapping.schema);
|
|
16
|
+
return {
|
|
17
|
+
description: mapping.description,
|
|
18
|
+
content: {
|
|
19
|
+
"application/json": {
|
|
20
|
+
schema: mapping.nullable ? { anyOf: [schema, { type: "null" }] } : schema,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function renderOpenApi() {
|
|
26
|
+
const manifest = listenerSdkManifest;
|
|
27
|
+
const envDescriptions = Object.fromEntries(manifest.env.map((entry) => [entry.name, entry.description]));
|
|
28
|
+
const zodComponents = buildZodOpenApiComponents();
|
|
29
|
+
const paths = {
|
|
30
|
+
"/api/v1/stations/{stationSlug}": {
|
|
31
|
+
get: {
|
|
32
|
+
operationId: "getStation",
|
|
33
|
+
summary: "Published station metadata",
|
|
34
|
+
tags: ["listener"],
|
|
35
|
+
security: [{ bearerListener: [] }],
|
|
36
|
+
parameters: [
|
|
37
|
+
{
|
|
38
|
+
name: "stationSlug",
|
|
39
|
+
in: "path",
|
|
40
|
+
required: true,
|
|
41
|
+
schema: { type: "string" },
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
responses: {
|
|
45
|
+
"200": successResponse("getStation"),
|
|
46
|
+
"401": { description: "Missing or invalid listener token", content: errorResponseContent() },
|
|
47
|
+
"404": { description: "Station not found", content: errorResponseContent() },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
[manifest.integrations.playback.path]: {
|
|
52
|
+
post: {
|
|
53
|
+
operationId: "reportPlayback",
|
|
54
|
+
summary: "Encoder playback observation",
|
|
55
|
+
tags: ["ingest"],
|
|
56
|
+
security: [{ bearerIngest: [] }],
|
|
57
|
+
parameters: [
|
|
58
|
+
{
|
|
59
|
+
name: "stationSlug",
|
|
60
|
+
in: "query",
|
|
61
|
+
required: false,
|
|
62
|
+
schema: { type: "string" },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "stationId",
|
|
66
|
+
in: "query",
|
|
67
|
+
required: false,
|
|
68
|
+
schema: { type: "string" },
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
requestBody: {
|
|
72
|
+
required: true,
|
|
73
|
+
content: {
|
|
74
|
+
"application/json": {
|
|
75
|
+
schema: jsonSchemaRef("PlaybackObservationIngestBody"),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
responses: {
|
|
80
|
+
"200": successResponse("reportPlayback"),
|
|
81
|
+
"401": { description: "Missing or invalid ingest token", content: errorResponseContent() },
|
|
82
|
+
"503": { description: "Temporary failure", content: errorResponseContent() },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const queryParamsByOperation = {
|
|
88
|
+
getContent: [
|
|
89
|
+
{ name: "type", required: true, schema: { type: "string" } },
|
|
90
|
+
{ name: "cursor", schema: { type: "string", format: "uuid" } },
|
|
91
|
+
{ name: "limit", schema: { type: "integer", minimum: 1, maximum: 50, default: 25 } },
|
|
92
|
+
],
|
|
93
|
+
getSchedule: [
|
|
94
|
+
{ name: "fromDate", required: true, schema: { type: "string", format: "date" } },
|
|
95
|
+
{ name: "throughDate", required: true, schema: { type: "string", format: "date" } },
|
|
96
|
+
],
|
|
97
|
+
getPrograms: [
|
|
98
|
+
{ name: "fromDate", required: true, schema: { type: "string", format: "date" } },
|
|
99
|
+
{ name: "throughDate", required: true, schema: { type: "string", format: "date" } },
|
|
100
|
+
],
|
|
101
|
+
getTrackHistory: [
|
|
102
|
+
{ name: "cursor", schema: { type: "string" } },
|
|
103
|
+
{ name: "limit", schema: { type: "integer", minimum: 1, maximum: 50, default: 25 } },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
for (const method of manifest.clientMethods) {
|
|
107
|
+
if (!method.rest || method.rest.method !== "GET" || !method.rest.resourceKey)
|
|
108
|
+
continue;
|
|
109
|
+
if (method.name === "getStation")
|
|
110
|
+
continue;
|
|
111
|
+
const pathKey = method.rest.resourceKey === "content"
|
|
112
|
+
? `/api/v1/stations/{stationSlug}/content`
|
|
113
|
+
: method.rest.resourceKey === "schemas"
|
|
114
|
+
? `/api/v1/stations/{stationSlug}/schemas`
|
|
115
|
+
: `/api/v1/stations/{stationSlug}/${method.rest.resourceKey}`;
|
|
116
|
+
const extraParams = queryParamsByOperation[method.name] ?? [];
|
|
117
|
+
paths[pathKey] = {
|
|
118
|
+
get: {
|
|
119
|
+
operationId: method.name,
|
|
120
|
+
summary: method.description,
|
|
121
|
+
tags: ["listener"],
|
|
122
|
+
security: [{ bearerListener: [] }],
|
|
123
|
+
parameters: [
|
|
124
|
+
{ name: "stationSlug", in: "path", required: true, schema: { type: "string" } },
|
|
125
|
+
...extraParams.map((param) => ({
|
|
126
|
+
name: param.name,
|
|
127
|
+
in: "query",
|
|
128
|
+
required: param.required ?? false,
|
|
129
|
+
schema: param.schema,
|
|
130
|
+
})),
|
|
131
|
+
],
|
|
132
|
+
responses: {
|
|
133
|
+
"200": successResponse(method.name),
|
|
134
|
+
"401": { description: "Unauthorized", content: errorResponseContent() },
|
|
135
|
+
"404": { description: "Resource disabled or not found", content: errorResponseContent() },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
if (method.name === "getContent") {
|
|
140
|
+
paths[`/api/v1/stations/{stationSlug}/content/{id}`] = {
|
|
141
|
+
get: {
|
|
142
|
+
operationId: "getContentRecord",
|
|
143
|
+
summary: "One published content record by id",
|
|
144
|
+
tags: ["listener"],
|
|
145
|
+
security: [{ bearerListener: [] }],
|
|
146
|
+
parameters: [
|
|
147
|
+
{ name: "stationSlug", in: "path", required: true, schema: { type: "string" } },
|
|
148
|
+
{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } },
|
|
149
|
+
],
|
|
150
|
+
responses: {
|
|
151
|
+
"200": successResponse("getContentRecord"),
|
|
152
|
+
"404": { description: "Not found", content: errorResponseContent() },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const doc = {
|
|
159
|
+
openapi: "3.1.0",
|
|
160
|
+
info: {
|
|
161
|
+
title: "RadioTools Listener API",
|
|
162
|
+
version: `${manifest.contractVersion}.0.0`,
|
|
163
|
+
description: "OpenAPI generated from @rivi-software/radiotools-contract paths and @rivi-software/radiotools-sdk Zod schemas (sdkSchemas). Station resource hrefs may differ from these templates.",
|
|
164
|
+
},
|
|
165
|
+
servers: [
|
|
166
|
+
{
|
|
167
|
+
url: "{RADIOTOOLS_BASE_URL}",
|
|
168
|
+
variables: { RADIOTOOLS_BASE_URL: { default: "https://radio.example" } },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
tags: [
|
|
172
|
+
{ name: "listener", description: "Requires listener:read (RADIOTOOLS_API_TOKEN)" },
|
|
173
|
+
{ name: "ingest", description: "Requires ingest:write (RADIOTOOLS_INGEST_TOKEN)" },
|
|
174
|
+
],
|
|
175
|
+
components: {
|
|
176
|
+
securitySchemes: {
|
|
177
|
+
bearerListener: {
|
|
178
|
+
type: "http",
|
|
179
|
+
scheme: "bearer",
|
|
180
|
+
description: envDescriptions.RADIOTOOLS_API_TOKEN ?? "Station listener key",
|
|
181
|
+
},
|
|
182
|
+
bearerIngest: {
|
|
183
|
+
type: "http",
|
|
184
|
+
scheme: "bearer",
|
|
185
|
+
description: envDescriptions.RADIOTOOLS_INGEST_TOKEN ?? "Playback ingest key",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
schemas: {
|
|
189
|
+
...zodComponents.schemas,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
paths,
|
|
193
|
+
"x-radiotools": {
|
|
194
|
+
contractVersion: manifest.contractVersion,
|
|
195
|
+
socketProtocol: manifest.socketProtocol,
|
|
196
|
+
zodSchemaSource: "@rivi-software/radiotools-sdk/schemas#sdkSchemas",
|
|
197
|
+
graphqlSchema: "@rivi-software/radiotools-contract/graphql",
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
201
|
+
}
|
|
202
|
+
export const openapiTarget = {
|
|
203
|
+
id: "openapi",
|
|
204
|
+
description: "OpenAPI 3.1 with components from Zod sdkSchemas (packages/openapi)",
|
|
205
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/openapi`,
|
|
206
|
+
generate: () => [
|
|
207
|
+
{ path: "openapi.json", contents: renderOpenApi() },
|
|
208
|
+
{
|
|
209
|
+
path: "README.md",
|
|
210
|
+
contents: `# RadioTools OpenAPI
|
|
211
|
+
|
|
212
|
+
\`openapi.json\` is generated from:
|
|
213
|
+
|
|
214
|
+
- REST paths in \`@rivi-software/radiotools-contract\`
|
|
215
|
+
- Response and request bodies from \`@rivi-software/radiotools-sdk\` **Zod** types (\`sdkSchemas\`) via \`z.toJSONSchema\`
|
|
216
|
+
|
|
217
|
+
\`\`\`bash
|
|
218
|
+
bun run --filter @rivi-software/radiotools-sdk-generator generate --target openapi
|
|
219
|
+
\`\`\`
|
|
220
|
+
`,
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { listenerSdkManifest } from "@rivi-software/radiotools-contract";
|
|
2
|
+
function renderPhpClient() {
|
|
3
|
+
const playbackPath = listenerSdkManifest.integrations.playback.path;
|
|
4
|
+
return `<?php
|
|
5
|
+
// Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand.
|
|
6
|
+
declare(strict_types=1);
|
|
7
|
+
|
|
8
|
+
namespace RadioTools\\Sdk;
|
|
9
|
+
|
|
10
|
+
final class StationClient
|
|
11
|
+
{
|
|
12
|
+
public function __construct(
|
|
13
|
+
private readonly string $baseUrl,
|
|
14
|
+
private readonly string $stationSlug,
|
|
15
|
+
private readonly ?string $apiToken = null,
|
|
16
|
+
private readonly ?string $ingestToken = null,
|
|
17
|
+
) {
|
|
18
|
+
if (($this->apiToken ?? '') === '' && ($this->ingestToken ?? '') === '') {
|
|
19
|
+
throw new \\InvalidArgumentException('apiToken or ingestToken is required');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public static function fromEnv(): self
|
|
24
|
+
{
|
|
25
|
+
$base = trim(getenv('RADIOTOOLS_BASE_URL') ?: '');
|
|
26
|
+
$slug = trim(getenv('RADIOTOOLS_STATION_SLUG') ?: '');
|
|
27
|
+
if ($base === '' || $slug === '') {
|
|
28
|
+
throw new \\RuntimeException('Set RADIOTOOLS_BASE_URL and RADIOTOOLS_STATION_SLUG');
|
|
29
|
+
}
|
|
30
|
+
return new self(
|
|
31
|
+
$base,
|
|
32
|
+
$slug,
|
|
33
|
+
getenv('RADIOTOOLS_API_TOKEN') ?: null,
|
|
34
|
+
getenv('RADIOTOOLS_INGEST_TOKEN') ?: null,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @return array<string, mixed> */
|
|
39
|
+
public function getStation(): array
|
|
40
|
+
{
|
|
41
|
+
$path = '/api/v1/stations/' . rawurlencode($this->stationSlug);
|
|
42
|
+
return $this->getJson($path, ingest: false);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @return array<string, mixed>|null */
|
|
46
|
+
public function getWeather(): ?array
|
|
47
|
+
{
|
|
48
|
+
$station = $this->getStation();
|
|
49
|
+
$href = $station['resources']['weather'] ?? null;
|
|
50
|
+
if (!is_string($href) || $href === '') {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return $this->getJson($href, ingest: false);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @return array<string, mixed>|null */
|
|
57
|
+
public function getTraffic(): ?array
|
|
58
|
+
{
|
|
59
|
+
$station = $this->getStation();
|
|
60
|
+
$href = $station['resources']['traffic'] ?? null;
|
|
61
|
+
if (!is_string($href) || $href === '') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return $this->getJson($href, ingest: false);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param array<string, mixed> $observation
|
|
68
|
+
* @return array<string, mixed>
|
|
69
|
+
*/
|
|
70
|
+
public function reportPlayback(array $observation, ?string $stationId = null): array
|
|
71
|
+
{
|
|
72
|
+
$query = $stationId !== null
|
|
73
|
+
? 'stationId=' . rawurlencode($stationId)
|
|
74
|
+
: 'stationSlug=' . rawurlencode($this->stationSlug);
|
|
75
|
+
return $this->postJson('${playbackPath}?' . $query, $observation, ingest: true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @return array<string, mixed> */
|
|
79
|
+
private function getJson(string $path, bool $ingest): array
|
|
80
|
+
{
|
|
81
|
+
$url = str_starts_with($path, 'http') ? $path : rtrim($this->baseUrl, '/') . $path;
|
|
82
|
+
return $this->request('GET', $url, null, $ingest);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param array<string, mixed> $body
|
|
86
|
+
* @return array<string, mixed>
|
|
87
|
+
*/
|
|
88
|
+
private function postJson(string $path, array $body, bool $ingest): array
|
|
89
|
+
{
|
|
90
|
+
$url = rtrim($this->baseUrl, '/') . $path;
|
|
91
|
+
return $this->request('POST', $url, json_encode($body, JSON_THROW_ON_ERROR), $ingest);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @return array<string, mixed> */
|
|
95
|
+
private function request(string $method, string $url, ?string $body, bool $ingest): array
|
|
96
|
+
{
|
|
97
|
+
$token = $ingest ? $this->ingestToken : $this->apiToken;
|
|
98
|
+
if ($token === null || $token === '') {
|
|
99
|
+
throw new \\RuntimeException('Missing bearer token');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
$ch = curl_init($url);
|
|
103
|
+
if ($ch === false) {
|
|
104
|
+
throw new \\RuntimeException('curl_init failed');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
$headers = [
|
|
108
|
+
'Authorization: Bearer ' . $token,
|
|
109
|
+
'Accept: application/json',
|
|
110
|
+
];
|
|
111
|
+
if ($body !== null) {
|
|
112
|
+
$headers[] = 'Content-Type: application/json';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
curl_setopt_array($ch, [
|
|
116
|
+
CURLOPT_RETURNTRANSFER => true,
|
|
117
|
+
CURLOPT_CUSTOMREQUEST => $method,
|
|
118
|
+
CURLOPT_HTTPHEADER => $headers,
|
|
119
|
+
CURLOPT_POSTFIELDS => $body,
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
$raw = curl_exec($ch);
|
|
123
|
+
$code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
124
|
+
curl_close($ch);
|
|
125
|
+
|
|
126
|
+
if ($raw === false || $code >= 400) {
|
|
127
|
+
throw new \\RuntimeException("HTTP {$code}: " . (string) $raw);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @var array<string, mixed> */
|
|
131
|
+
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
export const phpTarget = {
|
|
137
|
+
id: "php",
|
|
138
|
+
description: "PHP curl client stubs (packages/sdk-php)",
|
|
139
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-php/src`,
|
|
140
|
+
generate: () => [
|
|
141
|
+
{ path: "StationClient.php", contents: renderPhpClient() },
|
|
142
|
+
{
|
|
143
|
+
path: "../composer.json",
|
|
144
|
+
contents: JSON.stringify({
|
|
145
|
+
name: "radiotools/sdk",
|
|
146
|
+
description: "Generated RadioTools listener REST stubs",
|
|
147
|
+
type: "library",
|
|
148
|
+
license: "MIT",
|
|
149
|
+
require: { php: ">=8.2" },
|
|
150
|
+
autoload: {
|
|
151
|
+
"psr-4": {
|
|
152
|
+
"RadioTools\\Sdk\\": "src/",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}, null, 2),
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
path: "../README.md",
|
|
159
|
+
contents: `# radiotools/sdk (PHP)
|
|
160
|
+
|
|
161
|
+
Generated REST stubs. Regenerate: \`bun run --filter @rivi-software/radiotools-sdk-generator generate --target php\`.
|
|
162
|
+
`,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|