@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,124 @@
|
|
|
1
|
+
import { listenerSdkManifest } from "@rivi-software/radiotools-contract";
|
|
2
|
+
import { snakeCase } from "../shared/naming";
|
|
3
|
+
function renderPythonClient() {
|
|
4
|
+
const manifest = listenerSdkManifest;
|
|
5
|
+
const readMethods = manifest.clientMethods.filter((m) => m.scope === "listener:read" && m.rest?.method === "GET" && m.name.startsWith("get"));
|
|
6
|
+
const methodBlocks = readMethods
|
|
7
|
+
.map((method) => {
|
|
8
|
+
const pyName = snakeCase(method.name);
|
|
9
|
+
if (method.name === "getStation") {
|
|
10
|
+
return ` def ${pyName}(self) -> dict[str, Any]:
|
|
11
|
+
"""${method.description}"""
|
|
12
|
+
return self._get_json(f"/api/v1/stations/{quote(self.station_slug, safe='')}")`;
|
|
13
|
+
}
|
|
14
|
+
if (method.name === "getWeather" || method.name === "getTraffic") {
|
|
15
|
+
return ` def ${pyName}(self) -> dict[str, Any] | None:
|
|
16
|
+
"""${method.description}"""
|
|
17
|
+
station = self.get_station()
|
|
18
|
+
href = station.get("resources", {}).get("${method.name === "getWeather" ? "weather" : "traffic"}")
|
|
19
|
+
if not href:
|
|
20
|
+
return None
|
|
21
|
+
return self._get_json(href)`;
|
|
22
|
+
}
|
|
23
|
+
return ` def ${pyName}(self, **params: Any) -> dict[str, Any]:
|
|
24
|
+
"""${method.description}"""
|
|
25
|
+
raise NotImplementedError("Use the TypeScript SDK or extend this stub with REST href resolution.")`;
|
|
26
|
+
})
|
|
27
|
+
.join("\n\n");
|
|
28
|
+
return `"""Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand."""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Any
|
|
32
|
+
from urllib.parse import quote
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RadioToolsStationClient:
|
|
38
|
+
"""Minimal REST client for RadioTools listener contract v${manifest.contractVersion}."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
base_url: str,
|
|
43
|
+
station_slug: str,
|
|
44
|
+
*,
|
|
45
|
+
api_token: str | None = None,
|
|
46
|
+
ingest_token: str | None = None,
|
|
47
|
+
client: httpx.Client | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
if not api_token and not ingest_token:
|
|
50
|
+
raise ValueError("api_token or ingest_token is required")
|
|
51
|
+
self.base_url = base_url.rstrip("/")
|
|
52
|
+
self.station_slug = station_slug
|
|
53
|
+
self.api_token = api_token
|
|
54
|
+
self.ingest_token = ingest_token
|
|
55
|
+
self._client = client or httpx.Client(timeout=30.0)
|
|
56
|
+
|
|
57
|
+
def _headers(self, *, ingest: bool = False) -> dict[str, str]:
|
|
58
|
+
token = self.ingest_token if ingest else self.api_token
|
|
59
|
+
if not token:
|
|
60
|
+
scope = "ingest:write" if ingest else "listener:read"
|
|
61
|
+
raise ValueError(f"Missing token for {scope}")
|
|
62
|
+
return {"Authorization": f"Bearer {token}"}
|
|
63
|
+
|
|
64
|
+
def _get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
65
|
+
url = path if path.startswith("http") else f"{self.base_url}{path}"
|
|
66
|
+
response = self._client.get(
|
|
67
|
+
url,
|
|
68
|
+
params=params,
|
|
69
|
+
headers=self._headers(),
|
|
70
|
+
)
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
return response.json()
|
|
73
|
+
|
|
74
|
+
def _post_json(self, path: str, body: dict[str, Any], *, ingest: bool = True) -> dict[str, Any]:
|
|
75
|
+
url = f"{self.base_url}{path}"
|
|
76
|
+
response = self._client.post(url, json=body, headers=self._headers(ingest=ingest))
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
return response.json()
|
|
79
|
+
|
|
80
|
+
${methodBlocks}
|
|
81
|
+
|
|
82
|
+
def report_playback(self, observation: dict[str, Any], *, station_id: str | None = None) -> dict[str, Any]:
|
|
83
|
+
"""Send one encoder playback observation."""
|
|
84
|
+
params: dict[str, str] = {}
|
|
85
|
+
if station_id:
|
|
86
|
+
params["stationId"] = station_id
|
|
87
|
+
else:
|
|
88
|
+
params["stationSlug"] = self.station_slug
|
|
89
|
+
path = "${manifest.integrations.playback.path}"
|
|
90
|
+
query = "&".join(f"{k}={quote(v, safe='')}" for k, v in params.items())
|
|
91
|
+
return self._post_json(f"{path}?{query}", observation)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_env(cls) -> RadioToolsStationClient:
|
|
95
|
+
import os
|
|
96
|
+
|
|
97
|
+
base_url = os.environ.get("RADIOTOOLS_BASE_URL", "").strip()
|
|
98
|
+
station_slug = os.environ.get("RADIOTOOLS_STATION_SLUG", "").strip()
|
|
99
|
+
api_token = os.environ.get("RADIOTOOLS_API_TOKEN", "").strip() or None
|
|
100
|
+
ingest_token = os.environ.get("RADIOTOOLS_INGEST_TOKEN", "").strip() or None
|
|
101
|
+
if not base_url or not station_slug:
|
|
102
|
+
raise ValueError("Set RADIOTOOLS_BASE_URL and RADIOTOOLS_STATION_SLUG")
|
|
103
|
+
return cls(base_url, station_slug, api_token=api_token, ingest_token=ingest_token)
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
export const pythonTarget = {
|
|
107
|
+
id: "python",
|
|
108
|
+
description: "Python httpx client stubs (packages/sdk-python)",
|
|
109
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-python/src/radiotools_sdk`,
|
|
110
|
+
generate: () => [
|
|
111
|
+
{
|
|
112
|
+
path: "client.py",
|
|
113
|
+
contents: renderPythonClient(),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
path: "__init__.py",
|
|
117
|
+
contents: `"""${listenerSdkManifest.packageNames.python} — generated listener SDK."""
|
|
118
|
+
from .client import RadioToolsStationClient
|
|
119
|
+
|
|
120
|
+
__all__ = ["RadioToolsStationClient"]
|
|
121
|
+
`,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { hookNameFromMethod, ZERO_ARG_READ_METHODS } from "../shared/reads";
|
|
2
|
+
function renderReactHooks() {
|
|
3
|
+
const hooks = ZERO_ARG_READ_METHODS.map((method) => {
|
|
4
|
+
const hookName = hookNameFromMethod(method);
|
|
5
|
+
return `export function ${hookName}(client: StationClient) {
|
|
6
|
+
return useStationQuery(client, "${method}", () => client.${method}());
|
|
7
|
+
}`;
|
|
8
|
+
}).join("\n\n");
|
|
9
|
+
return `/** Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand. */
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
+
import type { StationClient } from "@rivi-software/radiotools-sdk";
|
|
12
|
+
|
|
13
|
+
type QueryState<T> = {
|
|
14
|
+
data: T | undefined;
|
|
15
|
+
error: Error | undefined;
|
|
16
|
+
loading: boolean;
|
|
17
|
+
refresh: () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function useStationQuery<T>(
|
|
21
|
+
client: StationClient,
|
|
22
|
+
_method: string,
|
|
23
|
+
read: () => Promise<T>,
|
|
24
|
+
): QueryState<T> {
|
|
25
|
+
const [data, setData] = useState<T | undefined>(undefined);
|
|
26
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
const mounted = useRef(true);
|
|
29
|
+
|
|
30
|
+
const refresh = useCallback(() => {
|
|
31
|
+
setLoading(true);
|
|
32
|
+
void read()
|
|
33
|
+
.then((value) => {
|
|
34
|
+
if (!mounted.current) return;
|
|
35
|
+
setData(value);
|
|
36
|
+
setError(undefined);
|
|
37
|
+
})
|
|
38
|
+
.catch((cause: unknown) => {
|
|
39
|
+
if (!mounted.current) return;
|
|
40
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
41
|
+
})
|
|
42
|
+
.finally(() => {
|
|
43
|
+
if (mounted.current) setLoading(false);
|
|
44
|
+
});
|
|
45
|
+
}, [read]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
mounted.current = true;
|
|
49
|
+
refresh();
|
|
50
|
+
return () => {
|
|
51
|
+
mounted.current = false;
|
|
52
|
+
};
|
|
53
|
+
}, [refresh]);
|
|
54
|
+
|
|
55
|
+
return { data, error, loading, refresh };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useRecommendedPollInterval(client: StationClient): QueryState<number> {
|
|
59
|
+
return useStationQuery(client, "recommendedPollIntervalMs", () =>
|
|
60
|
+
client.recommendedPollIntervalMs(),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
${hooks}
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
export const reactTarget = {
|
|
68
|
+
id: "react",
|
|
69
|
+
description: "React hooks wrapping @rivi-software/radiotools-sdk (packages/sdk-react)",
|
|
70
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-react/src`,
|
|
71
|
+
generate: () => [
|
|
72
|
+
{
|
|
73
|
+
path: "hooks.generated.ts",
|
|
74
|
+
contents: renderReactHooks(),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
path: "index.ts",
|
|
78
|
+
contents: `export * from "./hooks.generated";
|
|
79
|
+
`,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { listenerSdkManifest } from "@rivi-software/radiotools-contract";
|
|
2
|
+
function renderRubyClient() {
|
|
3
|
+
const playbackPath = listenerSdkManifest.integrations.playback.path;
|
|
4
|
+
return `# Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand.
|
|
5
|
+
# frozen_string_literal: true
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require "net/http"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module RadioTools
|
|
12
|
+
class StationClient
|
|
13
|
+
def initialize(base_url:, station_slug:, api_token: nil, ingest_token: nil)
|
|
14
|
+
raise ArgumentError, "api_token or ingest_token is required" if api_token.to_s.empty? && ingest_token.to_s.empty?
|
|
15
|
+
|
|
16
|
+
@base_url = base_url.to_s.chomp("/")
|
|
17
|
+
@station_slug = station_slug
|
|
18
|
+
@api_token = api_token
|
|
19
|
+
@ingest_token = ingest_token
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_env
|
|
23
|
+
new(
|
|
24
|
+
base_url: ENV.fetch("RADIOTOOLS_BASE_URL", ""),
|
|
25
|
+
station_slug: ENV.fetch("RADIOTOOLS_STATION_SLUG", ""),
|
|
26
|
+
api_token: ENV["RADIOTOOLS_API_TOKEN"],
|
|
27
|
+
ingest_token: ENV["RADIOTOOLS_INGEST_TOKEN"],
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_station
|
|
32
|
+
get_json("/api/v1/stations/#{URI.encode_uri_component(@station_slug)}", ingest: false)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_weather
|
|
36
|
+
href = station_resource("weather")
|
|
37
|
+
return nil if href.nil? || href.empty?
|
|
38
|
+
|
|
39
|
+
get_json(href, ingest: false)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def get_traffic
|
|
43
|
+
href = station_resource("traffic")
|
|
44
|
+
return nil if href.nil? || href.empty?
|
|
45
|
+
|
|
46
|
+
get_json(href, ingest: false)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def report_playback(observation, station_id: nil)
|
|
50
|
+
params = station_id ? { stationId: station_id } : { stationSlug: @station_slug }
|
|
51
|
+
query = URI.encode_www_form(params)
|
|
52
|
+
post_json("${playbackPath}?#{query}", observation, ingest: true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def station_resource(key)
|
|
58
|
+
station = get_station
|
|
59
|
+
station.dig("resources", key)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get_json(path, ingest:)
|
|
63
|
+
uri = path.start_with?("http") ? URI(path) : URI("#{@base_url}#{path}")
|
|
64
|
+
request = Net::HTTP::Get.new(uri)
|
|
65
|
+
apply_auth!(request, ingest)
|
|
66
|
+
execute(request, uri)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def post_json(path, body, ingest:)
|
|
70
|
+
uri = URI("#{@base_url}#{path}")
|
|
71
|
+
request = Net::HTTP::Post.new(uri)
|
|
72
|
+
request["Content-Type"] = "application/json"
|
|
73
|
+
request.body = JSON.generate(body)
|
|
74
|
+
apply_auth!(request, ingest)
|
|
75
|
+
execute(request, uri)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def apply_auth!(request, ingest)
|
|
79
|
+
token = ingest ? @ingest_token : @api_token
|
|
80
|
+
raise ArgumentError, "missing bearer token" if token.to_s.empty?
|
|
81
|
+
|
|
82
|
+
request["Authorization"] = "Bearer #{token}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def execute(request, uri)
|
|
86
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
87
|
+
http.request(request)
|
|
88
|
+
end
|
|
89
|
+
raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
90
|
+
|
|
91
|
+
JSON.parse(response.body)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
export const rubyTarget = {
|
|
98
|
+
id: "ruby",
|
|
99
|
+
description: "Ruby Net::HTTP client stubs (packages/sdk-ruby)",
|
|
100
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-ruby/lib/radiotools_sdk`,
|
|
101
|
+
generate: () => [
|
|
102
|
+
{ path: "client.rb", contents: renderRubyClient() },
|
|
103
|
+
{
|
|
104
|
+
path: "../radiotools_sdk.rb",
|
|
105
|
+
contents: `# frozen_string_literal: true
|
|
106
|
+
|
|
107
|
+
require_relative "radiotools_sdk/client"
|
|
108
|
+
|
|
109
|
+
module RadioToolsSdk
|
|
110
|
+
Client = RadioTools::StationClient
|
|
111
|
+
end
|
|
112
|
+
`,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
path: "../../README.md",
|
|
116
|
+
contents: `# radiotools_sdk (Ruby)
|
|
117
|
+
|
|
118
|
+
Generated REST stubs. Regenerate: \`bun run --filter @rivi-software/radiotools-sdk-generator generate --target ruby\`.
|
|
119
|
+
`,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { pascalCase } from "../shared/naming";
|
|
2
|
+
import { ZERO_ARG_READ_METHODS } from "../shared/reads";
|
|
3
|
+
function renderSvelteStores() {
|
|
4
|
+
const stores = ZERO_ARG_READ_METHODS.map((method) => {
|
|
5
|
+
const storeFn = `create${pascalCase(method)}Store`;
|
|
6
|
+
return `export function ${storeFn}(client: StationClient) {
|
|
7
|
+
return createStationStore(() => client.${method}());
|
|
8
|
+
}`;
|
|
9
|
+
}).join("\n\n");
|
|
10
|
+
return `/** Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand. */
|
|
11
|
+
import { derived, writable, type Readable } from "svelte/store";
|
|
12
|
+
import type { StationClient } from "@rivi-software/radiotools-sdk";
|
|
13
|
+
|
|
14
|
+
export type StationStoreState<T> = {
|
|
15
|
+
data: T | undefined;
|
|
16
|
+
error: Error | undefined;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type StationStore<T> = Readable<StationStoreState<T>> & {
|
|
21
|
+
refresh: () => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function createStationStore<T>(read: () => Promise<T>): StationStore<T> {
|
|
25
|
+
const inner = writable<StationStoreState<T>>({
|
|
26
|
+
data: undefined,
|
|
27
|
+
error: undefined,
|
|
28
|
+
loading: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const refresh = async () => {
|
|
32
|
+
inner.update((state) => ({ ...state, loading: true }));
|
|
33
|
+
try {
|
|
34
|
+
const data = await read();
|
|
35
|
+
inner.set({ data, error: undefined, loading: false });
|
|
36
|
+
} catch (cause: unknown) {
|
|
37
|
+
inner.set({
|
|
38
|
+
data: undefined,
|
|
39
|
+
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
40
|
+
loading: false,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
void refresh();
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
subscribe: inner.subscribe,
|
|
49
|
+
refresh,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createRecommendedPollIntervalStore(client: StationClient) {
|
|
54
|
+
return createStationStore(() => client.recommendedPollIntervalMs());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
${stores}
|
|
58
|
+
|
|
59
|
+
/** Convenience derived store: true when any nested field is loading. */
|
|
60
|
+
export function stationStoreLoading<T>(store: StationStore<T>): Readable<boolean> {
|
|
61
|
+
return derived(store, ($state) => $state.loading);
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
export const svelteTarget = {
|
|
66
|
+
id: "svelte",
|
|
67
|
+
description: "Svelte stores wrapping @rivi-software/radiotools-sdk (packages/sdk-svelte)",
|
|
68
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-svelte/src`,
|
|
69
|
+
generate: () => [
|
|
70
|
+
{ path: "stores.generated.ts", contents: renderSvelteStores() },
|
|
71
|
+
{
|
|
72
|
+
path: "index.ts",
|
|
73
|
+
contents: `export * from "./stores.generated";\n`,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { listenerSdkManifest } from "@rivi-software/radiotools-contract";
|
|
2
|
+
function renderSwiftClient() {
|
|
3
|
+
const playbackPath = listenerSdkManifest.integrations.playback.path.replace(/^\//, "");
|
|
4
|
+
return `// Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand.
|
|
5
|
+
import Foundation
|
|
6
|
+
|
|
7
|
+
public enum RadioToolsSDKError: Error, LocalizedError {
|
|
8
|
+
case missingToken
|
|
9
|
+
case invalidURL
|
|
10
|
+
case httpStatus(Int, String)
|
|
11
|
+
|
|
12
|
+
public var errorDescription: String? {
|
|
13
|
+
switch self {
|
|
14
|
+
case .missingToken: return "Missing bearer token"
|
|
15
|
+
case .invalidURL: return "Invalid URL"
|
|
16
|
+
case let .httpStatus(code, body): return "HTTP \\(code): \\(body)"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public final class StationClient: @unchecked Sendable {
|
|
22
|
+
private let baseURL: URL
|
|
23
|
+
private let stationSlug: String
|
|
24
|
+
private let apiToken: String?
|
|
25
|
+
private let ingestToken: String?
|
|
26
|
+
private let session: URLSession
|
|
27
|
+
|
|
28
|
+
public init(
|
|
29
|
+
baseURL: URL,
|
|
30
|
+
stationSlug: String,
|
|
31
|
+
apiToken: String? = nil,
|
|
32
|
+
ingestToken: String? = nil,
|
|
33
|
+
session: URLSession = .shared
|
|
34
|
+
) throws {
|
|
35
|
+
guard apiToken?.isEmpty == false || ingestToken?.isEmpty == false else {
|
|
36
|
+
throw RadioToolsSDKError.missingToken
|
|
37
|
+
}
|
|
38
|
+
self.baseURL = baseURL
|
|
39
|
+
self.stationSlug = stationSlug
|
|
40
|
+
self.apiToken = apiToken
|
|
41
|
+
self.ingestToken = ingestToken
|
|
42
|
+
self.session = session
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public func getStation() async throws -> [String: Any] {
|
|
46
|
+
let path = "/api/v1/stations/\\(stationSlug.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? stationSlug)"
|
|
47
|
+
return try await getJSON(path: path, ingest: false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public func getNowPlaying() async throws -> [String: Any] {
|
|
51
|
+
let station = try await getStation()
|
|
52
|
+
guard
|
|
53
|
+
let resources = station["resources"] as? [String: Any],
|
|
54
|
+
let href = resources["nowPlaying"] as? String,
|
|
55
|
+
!href.isEmpty
|
|
56
|
+
else {
|
|
57
|
+
throw RadioToolsSDKError.httpStatus(404, "nowPlaying unavailable")
|
|
58
|
+
}
|
|
59
|
+
return try await getJSON(path: href, ingest: false)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public func getWeather() async throws -> [String: Any]? {
|
|
63
|
+
let station = try await getStation()
|
|
64
|
+
guard
|
|
65
|
+
let resources = station["resources"] as? [String: Any],
|
|
66
|
+
let href = resources["weather"] as? String,
|
|
67
|
+
!href.isEmpty
|
|
68
|
+
else { return nil }
|
|
69
|
+
return try await getJSON(path: href, ingest: false)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public func getTraffic() async throws -> [String: Any]? {
|
|
73
|
+
let station = try await getStation()
|
|
74
|
+
guard
|
|
75
|
+
let resources = station["resources"] as? [String: Any],
|
|
76
|
+
let href = resources["traffic"] as? String,
|
|
77
|
+
!href.isEmpty
|
|
78
|
+
else { return nil }
|
|
79
|
+
return try await getJSON(path: href, ingest: false)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public func reportPlayback(
|
|
83
|
+
observation: [String: Any],
|
|
84
|
+
stationId: String? = nil
|
|
85
|
+
) async throws -> [String: Any] {
|
|
86
|
+
var items: [URLQueryItem] = []
|
|
87
|
+
if let stationId, !stationId.isEmpty {
|
|
88
|
+
items.append(URLQueryItem(name: "stationId", value: stationId))
|
|
89
|
+
} else {
|
|
90
|
+
items.append(URLQueryItem(name: "stationSlug", value: stationSlug))
|
|
91
|
+
}
|
|
92
|
+
var components = URLComponents(url: baseURL.appendingPathComponent("${playbackPath}"), resolvingAgainstBaseURL: false)
|
|
93
|
+
components?.queryItems = items
|
|
94
|
+
guard let url = components?.url else { throw RadioToolsSDKError.invalidURL }
|
|
95
|
+
return try await postJSON(url: url, body: observation, ingest: true)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private func getJSON(path: String, ingest: Bool) async throws -> [String: Any] {
|
|
99
|
+
let url: URL
|
|
100
|
+
if path.hasPrefix("http") {
|
|
101
|
+
guard let parsed = URL(string: path) else { throw RadioToolsSDKError.invalidURL }
|
|
102
|
+
url = parsed
|
|
103
|
+
} else {
|
|
104
|
+
url = URL(string: path, relativeTo: baseURL)?.absoluteURL ?? baseURL
|
|
105
|
+
}
|
|
106
|
+
var request = URLRequest(url: url)
|
|
107
|
+
request.httpMethod = "GET"
|
|
108
|
+
request.setValue(try bearer(ingest: ingest), forHTTPHeaderField: "Authorization")
|
|
109
|
+
let (data, response) = try await session.data(for: request)
|
|
110
|
+
try validate(response: response, data: data)
|
|
111
|
+
let object = try JSONSerialization.jsonObject(with: data)
|
|
112
|
+
guard let dict = object as? [String: Any] else { return [:] }
|
|
113
|
+
return dict
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func postJSON(url: URL, body: [String: Any], ingest: Bool) async throws -> [String: Any] {
|
|
117
|
+
var request = URLRequest(url: url)
|
|
118
|
+
request.httpMethod = "POST"
|
|
119
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
120
|
+
request.setValue(try bearer(ingest: ingest), forHTTPHeaderField: "Authorization")
|
|
121
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
122
|
+
let (data, response) = try await session.data(for: request)
|
|
123
|
+
try validate(response: response, data: data)
|
|
124
|
+
let object = try JSONSerialization.jsonObject(with: data)
|
|
125
|
+
return object as? [String: Any] ?? [:]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private func bearer(ingest: Bool) throws -> String {
|
|
129
|
+
let token = ingest ? ingestToken : apiToken
|
|
130
|
+
guard let token, !token.isEmpty else { throw RadioToolsSDKError.missingToken }
|
|
131
|
+
return "Bearer \\(token)"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func validate(response: URLResponse, data: Data) throws {
|
|
135
|
+
guard let http = response as? HTTPURLResponse else { return }
|
|
136
|
+
guard (200 ... 299).contains(http.statusCode) else {
|
|
137
|
+
let body = String(data: data, encoding: .utf8) ?? ""
|
|
138
|
+
throw RadioToolsSDKError.httpStatus(http.statusCode, body)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
export const swiftTarget = {
|
|
145
|
+
id: "swift",
|
|
146
|
+
description: "Swift URLSession client stubs (packages/sdk-swift)",
|
|
147
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-swift/Sources/RadioToolsSDK`,
|
|
148
|
+
generate: (repoRoot) => [
|
|
149
|
+
{ path: "StationClient.swift", contents: renderSwiftClient() },
|
|
150
|
+
{
|
|
151
|
+
path: `${repoRoot}/packages/sdk-swift/Package.swift`,
|
|
152
|
+
contents: `// swift-tools-version: 6.0
|
|
153
|
+
import PackageDescription
|
|
154
|
+
|
|
155
|
+
let package = Package(
|
|
156
|
+
name: "RadioToolsSDK",
|
|
157
|
+
platforms: [.iOS(.v16), .macOS(.v13)],
|
|
158
|
+
products: [
|
|
159
|
+
.library(name: "RadioToolsSDK", targets: ["RadioToolsSDK"]),
|
|
160
|
+
],
|
|
161
|
+
targets: [
|
|
162
|
+
.target(name: "RadioToolsSDK"),
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
`,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
path: `${repoRoot}/packages/sdk-swift/README.md`,
|
|
169
|
+
contents: `# RadioTools Swift SDK
|
|
170
|
+
|
|
171
|
+
Generated iOS/macOS REST stubs using \`URLSession\` and async/await.
|
|
172
|
+
|
|
173
|
+
Regenerate: \`bun run --filter @rivi-software/radiotools-sdk-generator generate --target swift\`
|
|
174
|
+
|
|
175
|
+
Use \`packages/openapi/openapi.json\` (from Zod) to generate typed models with Swift OpenAPI Generator if needed.
|
|
176
|
+
`,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { hookNameFromMethod, ZERO_ARG_READ_METHODS } from "../shared/reads";
|
|
2
|
+
function renderVueComposables() {
|
|
3
|
+
const composables = ZERO_ARG_READ_METHODS.map((method) => {
|
|
4
|
+
const name = hookNameFromMethod(method);
|
|
5
|
+
return `export function ${name}(client: StationClient) {
|
|
6
|
+
return useStationQuery(() => client.${method}());
|
|
7
|
+
}`;
|
|
8
|
+
}).join("\n\n");
|
|
9
|
+
return `/** Generated by @rivi-software/radiotools-sdk-generator — do not edit by hand. */
|
|
10
|
+
import { type Ref, onMounted, onUnmounted, ref, shallowRef } from "vue";
|
|
11
|
+
import type { StationClient } from "@rivi-software/radiotools-sdk";
|
|
12
|
+
|
|
13
|
+
export type StationQuery<T> = {
|
|
14
|
+
data: Ref<T | undefined>;
|
|
15
|
+
error: Ref<Error | undefined>;
|
|
16
|
+
loading: Ref<boolean>;
|
|
17
|
+
refresh: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function useStationQuery<T>(read: () => Promise<T>): StationQuery<T> {
|
|
21
|
+
const data = shallowRef<T | undefined>(undefined);
|
|
22
|
+
const error = ref<Error | undefined>(undefined);
|
|
23
|
+
const loading = ref(true);
|
|
24
|
+
let active = true;
|
|
25
|
+
|
|
26
|
+
const refresh = async () => {
|
|
27
|
+
loading.value = true;
|
|
28
|
+
try {
|
|
29
|
+
const value = await read();
|
|
30
|
+
if (!active) return;
|
|
31
|
+
data.value = value;
|
|
32
|
+
error.value = undefined;
|
|
33
|
+
} catch (cause: unknown) {
|
|
34
|
+
if (!active) return;
|
|
35
|
+
error.value = cause instanceof Error ? cause : new Error(String(cause));
|
|
36
|
+
} finally {
|
|
37
|
+
if (active) loading.value = false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
onMounted(() => {
|
|
42
|
+
active = true;
|
|
43
|
+
void refresh();
|
|
44
|
+
});
|
|
45
|
+
onUnmounted(() => {
|
|
46
|
+
active = false;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return { data, error, loading, refresh };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useRecommendedPollInterval(client: StationClient): StationQuery<number> {
|
|
53
|
+
return useStationQuery(() => client.recommendedPollIntervalMs());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
${composables}
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
export const vueTarget = {
|
|
60
|
+
id: "vue",
|
|
61
|
+
description: "Vue composables wrapping @rivi-software/radiotools-sdk (packages/sdk-vue)",
|
|
62
|
+
outputDir: (repoRoot) => `${repoRoot}/packages/sdk-vue/src`,
|
|
63
|
+
generate: () => [
|
|
64
|
+
{ path: "composables.generated.ts", contents: renderVueComposables() },
|
|
65
|
+
{ path: "index.ts", contents: `export * from "./composables.generated";\n` },
|
|
66
|
+
],
|
|
67
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type GeneratorTargetId = "python" | "react" | "vue" | "svelte" | "go" | "csharp" | "ruby" | "php" | "openapi" | "kotlin" | "swift" | "next";
|
|
2
|
+
export type GeneratorTarget = GeneratorTargetId | "all";
|
|
3
|
+
export type GenerateOptions = {
|
|
4
|
+
repoRoot: string;
|
|
5
|
+
target: GeneratorTarget;
|
|
6
|
+
dryRun?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type GeneratedFile = {
|
|
9
|
+
path: string;
|
|
10
|
+
contents: string;
|
|
11
|
+
};
|
|
12
|
+
export type SdkTarget = {
|
|
13
|
+
id: GeneratorTargetId;
|
|
14
|
+
description: string;
|
|
15
|
+
outputDir: (repoRoot: string) => string;
|
|
16
|
+
generate: (repoRoot: string) => GeneratedFile[];
|
|
17
|
+
};
|