mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 05:01:17 +00:00
Merge pull request #3 from kennethnym/feat/data-source-weatherkit
Add WeatherKit data source package
This commit is contained in:
18
bun.lock
18
bun.lock
@@ -17,10 +17,24 @@
|
||||
"name": "@aris/core",
|
||||
"version": "0.0.0",
|
||||
},
|
||||
"packages/aris-data-source-weatherkit": {
|
||||
"name": "@aris/data-source-weatherkit",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"arktype": "^2.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
||||
|
||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||
|
||||
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
||||
|
||||
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
|
||||
|
||||
"@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A=="],
|
||||
|
||||
"@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg=="],
|
||||
@@ -57,6 +71,10 @@
|
||||
|
||||
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||
|
||||
"arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="],
|
||||
|
||||
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.24.0", "", { "dependencies": { "tinypool": "2.0.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.24.0", "@oxfmt/darwin-x64": "0.24.0", "@oxfmt/linux-arm64-gnu": "0.24.0", "@oxfmt/linux-arm64-musl": "0.24.0", "@oxfmt/linux-x64-gnu": "0.24.0", "@oxfmt/linux-x64-musl": "0.24.0", "@oxfmt/win32-arm64": "0.24.0", "@oxfmt/win32-x64": "0.24.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw=="],
|
||||
|
||||
4
packages/aris-data-source-weatherkit/.env.example
Normal file
4
packages/aris-data-source-weatherkit/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
WEATHERKIT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
WEATHERKIT_KEY_ID=ABC123DEFG
|
||||
WEATHERKIT_TEAM_ID=TEAM123456
|
||||
WEATHERKIT_SERVICE_ID=com.example.weatherkit.test
|
||||
58
packages/aris-data-source-weatherkit/README.md
Normal file
58
packages/aris-data-source-weatherkit/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# @aris/data-source-weatherkit
|
||||
|
||||
Weather data source using Apple WeatherKit REST API.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { WeatherKitDataSource, Units } from "@aris/data-source-weatherkit"
|
||||
|
||||
const dataSource = new WeatherKitDataSource({
|
||||
credentials: {
|
||||
privateKey: "-----BEGIN PRIVATE KEY-----\n...",
|
||||
keyId: "ABC123",
|
||||
teamId: "DEF456",
|
||||
serviceId: "com.example.weatherkit",
|
||||
},
|
||||
hourlyLimit: 12, // optional, default: 12
|
||||
dailyLimit: 7, // optional, default: 7
|
||||
})
|
||||
|
||||
const items = await dataSource.query(context, {
|
||||
units: Units.metric, // or Units.imperial
|
||||
})
|
||||
```
|
||||
|
||||
## Feed Items
|
||||
|
||||
The data source returns four types of feed items:
|
||||
|
||||
| Type | Description |
|
||||
| ----------------- | -------------------------- |
|
||||
| `weather-current` | Current weather conditions |
|
||||
| `weather-hourly` | Hourly forecast |
|
||||
| `weather-daily` | Daily forecast |
|
||||
| `weather-alert` | Weather alerts |
|
||||
|
||||
## Priority
|
||||
|
||||
Base priorities are adjusted based on weather conditions:
|
||||
|
||||
- Severe conditions (tornado, hurricane, blizzard, etc.): +0.3
|
||||
- Moderate conditions (thunderstorm, heavy rain, etc.): +0.15
|
||||
- Alert severity: extreme=1.0, severe=0.9, moderate=0.75, minor=0.7
|
||||
|
||||
## Authentication
|
||||
|
||||
WeatherKit requires Apple Developer credentials. Generate a private key in the Apple Developer portal under Certificates, Identifiers & Profiles > Keys.
|
||||
|
||||
## Validation
|
||||
|
||||
API responses are validated using [arktype](https://arktype.io) schemas.
|
||||
|
||||
## Generating Test Fixtures
|
||||
|
||||
To regenerate fixture data from the real API:
|
||||
|
||||
1. Create a `.env` file with your credentials (see `.env.example`)
|
||||
2. Run `bun run scripts/generate-fixtures.ts`
|
||||
6457
packages/aris-data-source-weatherkit/fixtures/san-francisco.json
Normal file
6457
packages/aris-data-source-weatherkit/fixtures/san-francisco.json
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/aris-data-source-weatherkit/package.json
Normal file
14
packages/aris-data-source-weatherkit/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@aris/data-source-weatherkit",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"arktype": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { DefaultWeatherKitClient } from "../src/weatherkit"
|
||||
|
||||
function loadEnv(): Record<string, string> {
|
||||
const content = require("fs").readFileSync(".env", "utf-8")
|
||||
const env: Record<string, string> = {}
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
|
||||
const eqIndex = trimmed.indexOf("=")
|
||||
if (eqIndex === -1) continue
|
||||
|
||||
const key = trimmed.slice(0, eqIndex)
|
||||
let value = trimmed.slice(eqIndex + 1)
|
||||
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
env[key] = value.replace(/\\n/g, "\n")
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
const env = loadEnv()
|
||||
|
||||
const client = new DefaultWeatherKitClient({
|
||||
privateKey: env.WEATHERKIT_PRIVATE_KEY!,
|
||||
keyId: env.WEATHERKIT_KEY_ID!,
|
||||
teamId: env.WEATHERKIT_TEAM_ID!,
|
||||
serviceId: env.WEATHERKIT_SERVICE_ID!,
|
||||
})
|
||||
|
||||
const locations = {
|
||||
sanFrancisco: { lat: 37.7749, lng: -122.4194 },
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Fetching weather data for San Francisco...")
|
||||
|
||||
const response = await client.fetch({
|
||||
lat: locations.sanFrancisco.lat,
|
||||
lng: locations.sanFrancisco.lng,
|
||||
})
|
||||
|
||||
const fixture = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
location: locations.sanFrancisco,
|
||||
response,
|
||||
}
|
||||
|
||||
const output = JSON.stringify(fixture)
|
||||
await Bun.write("fixtures/san-francisco.json", output)
|
||||
|
||||
console.log("Fixture written to fixtures/san-francisco.json")
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
210
packages/aris-data-source-weatherkit/src/data-source.test.ts
Normal file
210
packages/aris-data-source-weatherkit/src/data-source.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { Context } from "@aris/core"
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
||||
|
||||
import fixture from "../fixtures/san-francisco.json"
|
||||
import { WeatherKitDataSource, Units } from "./data-source"
|
||||
import { WeatherFeedItemType } from "./feed-items"
|
||||
|
||||
const mockCredentials = {
|
||||
privateKey: "mock",
|
||||
keyId: "mock",
|
||||
teamId: "mock",
|
||||
serviceId: "mock",
|
||||
}
|
||||
|
||||
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
||||
fetch: async () => response,
|
||||
})
|
||||
|
||||
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
|
||||
time: new Date("2026-01-17T00:00:00Z"),
|
||||
location: location ? { ...location, accuracy: 10 } : undefined,
|
||||
})
|
||||
|
||||
describe("WeatherKitDataSource", () => {
|
||||
test("returns empty array when location is missing", async () => {
|
||||
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
|
||||
const items = await dataSource.query(createMockContext())
|
||||
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("type is weather-current", () => {
|
||||
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
|
||||
|
||||
expect(dataSource.type).toBe(WeatherFeedItemType.current)
|
||||
})
|
||||
|
||||
test("throws error if neither client nor credentials provided", () => {
|
||||
expect(() => new WeatherKitDataSource({})).toThrow(
|
||||
"Either client or credentials must be provided",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("WeatherKitDataSource with fixture", () => {
|
||||
const response = fixture.response
|
||||
|
||||
test("parses current weather from fixture", () => {
|
||||
const current = response.currentWeather
|
||||
|
||||
expect(typeof current.conditionCode).toBe("string")
|
||||
expect(typeof current.temperature).toBe("number")
|
||||
expect(typeof current.humidity).toBe("number")
|
||||
expect(current.pressureTrend).toMatch(/^(rising|falling|steady)$/)
|
||||
})
|
||||
|
||||
test("parses hourly forecast from fixture", () => {
|
||||
const hours = response.forecastHourly.hours
|
||||
|
||||
expect(hours.length).toBeGreaterThan(0)
|
||||
|
||||
const firstHour = hours[0]!
|
||||
expect(firstHour.forecastStart).toBeDefined()
|
||||
expect(typeof firstHour.temperature).toBe("number")
|
||||
expect(typeof firstHour.precipitationChance).toBe("number")
|
||||
})
|
||||
|
||||
test("parses daily forecast from fixture", () => {
|
||||
const days = response.forecastDaily.days
|
||||
|
||||
expect(days.length).toBeGreaterThan(0)
|
||||
|
||||
const firstDay = days[0]!
|
||||
expect(firstDay.forecastStart).toBeDefined()
|
||||
expect(typeof firstDay.temperatureMax).toBe("number")
|
||||
expect(typeof firstDay.temperatureMin).toBe("number")
|
||||
expect(firstDay.sunrise).toBeDefined()
|
||||
expect(firstDay.sunset).toBeDefined()
|
||||
})
|
||||
|
||||
test("hourly limit is respected", () => {
|
||||
const dataSource = new WeatherKitDataSource({
|
||||
credentials: mockCredentials,
|
||||
hourlyLimit: 6,
|
||||
})
|
||||
|
||||
expect(dataSource["hourlyLimit"]).toBe(6)
|
||||
})
|
||||
|
||||
test("daily limit is respected", () => {
|
||||
const dataSource = new WeatherKitDataSource({
|
||||
credentials: mockCredentials,
|
||||
dailyLimit: 3,
|
||||
})
|
||||
|
||||
expect(dataSource["dailyLimit"]).toBe(3)
|
||||
})
|
||||
|
||||
test("default limits are applied", () => {
|
||||
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
|
||||
|
||||
expect(dataSource["hourlyLimit"]).toBe(12)
|
||||
expect(dataSource["dailyLimit"]).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe("unit conversion", () => {
|
||||
test("Units enum has metric and imperial", () => {
|
||||
expect(Units.metric).toBe("metric")
|
||||
expect(Units.imperial).toBe("imperial")
|
||||
})
|
||||
})
|
||||
|
||||
describe("query() with mocked client", () => {
|
||||
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
|
||||
|
||||
test("transforms API response into feed items", async () => {
|
||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await dataSource.query(context)
|
||||
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
|
||||
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
|
||||
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
|
||||
})
|
||||
|
||||
test("applies hourly and daily limits", async () => {
|
||||
const dataSource = new WeatherKitDataSource({
|
||||
client: mockClient,
|
||||
hourlyLimit: 3,
|
||||
dailyLimit: 2,
|
||||
})
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await dataSource.query(context)
|
||||
|
||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
|
||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
|
||||
|
||||
expect(hourlyItems.length).toBe(3)
|
||||
expect(dailyItems.length).toBe(2)
|
||||
})
|
||||
|
||||
test("sets timestamp from context.time", async () => {
|
||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||
const queryTime = new Date("2026-01-17T12:00:00Z")
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
context.time = queryTime
|
||||
|
||||
const items = await dataSource.query(context)
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.timestamp).toEqual(queryTime)
|
||||
}
|
||||
})
|
||||
|
||||
test("converts temperatures to imperial", async () => {
|
||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const metricItems = await dataSource.query(context, { units: Units.metric })
|
||||
const imperialItems = await dataSource.query(context, { units: Units.imperial })
|
||||
|
||||
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
|
||||
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)
|
||||
|
||||
expect(metricCurrent).toBeDefined()
|
||||
expect(imperialCurrent).toBeDefined()
|
||||
|
||||
const metricTemp = (metricCurrent!.data as { temperature: number }).temperature
|
||||
const imperialTemp = (imperialCurrent!.data as { temperature: number }).temperature
|
||||
|
||||
// Verify conversion: F = C * 9/5 + 32
|
||||
const expectedImperial = (metricTemp * 9) / 5 + 32
|
||||
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
|
||||
})
|
||||
|
||||
test("assigns priority based on weather conditions", async () => {
|
||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await dataSource.query(context)
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.priority).toBeGreaterThanOrEqual(0)
|
||||
expect(item.priority).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||
expect(currentItem).toBeDefined()
|
||||
// Base priority for current is 0.5, may be adjusted for conditions
|
||||
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
|
||||
})
|
||||
|
||||
test("generates unique IDs for each item", async () => {
|
||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await dataSource.query(context)
|
||||
const ids = items.map((i) => i.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
})
|
||||
306
packages/aris-data-source-weatherkit/src/data-source.ts
Normal file
306
packages/aris-data-source-weatherkit/src/data-source.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { Context, DataSource } from "@aris/core"
|
||||
|
||||
import {
|
||||
WeatherFeedItemType,
|
||||
type CurrentWeatherFeedItem,
|
||||
type DailyWeatherFeedItem,
|
||||
type HourlyWeatherFeedItem,
|
||||
type WeatherAlertFeedItem,
|
||||
type WeatherFeedItem,
|
||||
} from "./feed-items"
|
||||
import {
|
||||
ConditionCode,
|
||||
DefaultWeatherKitClient,
|
||||
Severity,
|
||||
type CurrentWeather,
|
||||
type DailyForecast,
|
||||
type HourlyForecast,
|
||||
type WeatherAlert,
|
||||
type WeatherKitClient,
|
||||
type WeatherKitCredentials,
|
||||
} from "./weatherkit"
|
||||
|
||||
export const Units = {
|
||||
metric: "metric",
|
||||
imperial: "imperial",
|
||||
} as const
|
||||
|
||||
export type Units = (typeof Units)[keyof typeof Units]
|
||||
|
||||
export interface WeatherKitDataSourceOptions {
|
||||
credentials?: WeatherKitCredentials
|
||||
client?: WeatherKitClient
|
||||
hourlyLimit?: number
|
||||
dailyLimit?: number
|
||||
}
|
||||
|
||||
export interface WeatherKitQueryConfig {
|
||||
units?: Units
|
||||
}
|
||||
|
||||
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||
private readonly DEFAULT_DAILY_LIMIT = 7
|
||||
|
||||
readonly type = WeatherFeedItemType.current
|
||||
private readonly client: WeatherKitClient
|
||||
private readonly hourlyLimit: number
|
||||
private readonly dailyLimit: number
|
||||
|
||||
constructor(options: WeatherKitDataSourceOptions) {
|
||||
if (!options.client && !options.credentials) {
|
||||
throw new Error("Either client or credentials must be provided")
|
||||
}
|
||||
this.client = options.client ?? new DefaultWeatherKitClient(options.credentials!)
|
||||
this.hourlyLimit = options.hourlyLimit ?? this.DEFAULT_HOURLY_LIMIT
|
||||
this.dailyLimit = options.dailyLimit ?? this.DEFAULT_DAILY_LIMIT
|
||||
}
|
||||
|
||||
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
||||
if (!context.location) {
|
||||
return []
|
||||
}
|
||||
|
||||
const units = config.units ?? Units.metric
|
||||
const timestamp = context.time
|
||||
|
||||
const response = await this.client.fetch({
|
||||
lat: context.location.lat,
|
||||
lng: context.location.lng,
|
||||
})
|
||||
|
||||
const items: WeatherFeedItem[] = []
|
||||
|
||||
if (response.currentWeather) {
|
||||
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, units))
|
||||
}
|
||||
|
||||
if (response.forecastHourly?.hours) {
|
||||
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
|
||||
for (let i = 0; i < hours.length; i++) {
|
||||
const hour = hours[i]
|
||||
if (hour) {
|
||||
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, units))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (response.forecastDaily?.days) {
|
||||
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const day = days[i]
|
||||
if (day) {
|
||||
items.push(createDailyWeatherFeedItem(day, i, timestamp, units))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (response.weatherAlerts?.alerts) {
|
||||
for (const alert of response.weatherAlerts.alerts) {
|
||||
items.push(createWeatherAlertFeedItem(alert, timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_PRIORITY = {
|
||||
current: 0.5,
|
||||
hourly: 0.3,
|
||||
daily: 0.2,
|
||||
alert: 0.7,
|
||||
} as const
|
||||
|
||||
const SEVERE_CONDITIONS = new Set<ConditionCode>([
|
||||
ConditionCode.SevereThunderstorm,
|
||||
ConditionCode.Hurricane,
|
||||
ConditionCode.Tornado,
|
||||
ConditionCode.TropicalStorm,
|
||||
ConditionCode.Blizzard,
|
||||
ConditionCode.FreezingRain,
|
||||
ConditionCode.Hail,
|
||||
ConditionCode.Frigid,
|
||||
ConditionCode.Hot,
|
||||
])
|
||||
|
||||
const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
||||
ConditionCode.Thunderstorm,
|
||||
ConditionCode.IsolatedThunderstorms,
|
||||
ConditionCode.ScatteredThunderstorms,
|
||||
ConditionCode.HeavyRain,
|
||||
ConditionCode.HeavySnow,
|
||||
ConditionCode.FreezingDrizzle,
|
||||
ConditionCode.BlowingSnow,
|
||||
])
|
||||
|
||||
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, basePriority + 0.3)
|
||||
}
|
||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, basePriority + 0.15)
|
||||
}
|
||||
return basePriority
|
||||
}
|
||||
|
||||
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||||
switch (severity) {
|
||||
case Severity.Extreme:
|
||||
return 1
|
||||
case Severity.Severe:
|
||||
return 0.9
|
||||
case Severity.Moderate:
|
||||
return 0.75
|
||||
case Severity.Minor:
|
||||
return BASE_PRIORITY.alert
|
||||
}
|
||||
}
|
||||
|
||||
function convertTemperature(celsius: number, units: Units): number {
|
||||
if (units === Units.imperial) {
|
||||
return (celsius * 9) / 5 + 32
|
||||
}
|
||||
return celsius
|
||||
}
|
||||
|
||||
function convertSpeed(kmh: number, units: Units): number {
|
||||
if (units === Units.imperial) {
|
||||
return kmh * 0.621371
|
||||
}
|
||||
return kmh
|
||||
}
|
||||
|
||||
function convertDistance(km: number, units: Units): number {
|
||||
if (units === Units.imperial) {
|
||||
return km * 0.621371
|
||||
}
|
||||
return km
|
||||
}
|
||||
|
||||
function convertPrecipitation(mm: number, units: Units): number {
|
||||
if (units === Units.imperial) {
|
||||
return mm * 0.0393701
|
||||
}
|
||||
return mm
|
||||
}
|
||||
|
||||
function convertPressure(mb: number, units: Units): number {
|
||||
if (units === Units.imperial) {
|
||||
return mb * 0.02953
|
||||
}
|
||||
return mb
|
||||
}
|
||||
|
||||
function createCurrentWeatherFeedItem(
|
||||
current: CurrentWeather,
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): CurrentWeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-current-${timestamp.getTime()}`,
|
||||
type: WeatherFeedItemType.current,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
conditionCode: current.conditionCode,
|
||||
daylight: current.daylight,
|
||||
humidity: current.humidity,
|
||||
precipitationIntensity: convertPrecipitation(current.precipitationIntensity, units),
|
||||
pressure: convertPressure(current.pressure, units),
|
||||
pressureTrend: current.pressureTrend,
|
||||
temperature: convertTemperature(current.temperature, units),
|
||||
temperatureApparent: convertTemperature(current.temperatureApparent, units),
|
||||
uvIndex: current.uvIndex,
|
||||
visibility: convertDistance(current.visibility, units),
|
||||
windDirection: current.windDirection,
|
||||
windGust: convertSpeed(current.windGust, units),
|
||||
windSpeed: convertSpeed(current.windSpeed, units),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createHourlyWeatherFeedItem(
|
||||
hourly: HourlyForecast,
|
||||
index: number,
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): HourlyWeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.hourly,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastTime: new Date(hourly.forecastStart),
|
||||
conditionCode: hourly.conditionCode,
|
||||
daylight: hourly.daylight,
|
||||
humidity: hourly.humidity,
|
||||
precipitationAmount: convertPrecipitation(hourly.precipitationAmount, units),
|
||||
precipitationChance: hourly.precipitationChance,
|
||||
precipitationType: hourly.precipitationType,
|
||||
temperature: convertTemperature(hourly.temperature, units),
|
||||
temperatureApparent: convertTemperature(hourly.temperatureApparent, units),
|
||||
uvIndex: hourly.uvIndex,
|
||||
windDirection: hourly.windDirection,
|
||||
windGust: convertSpeed(hourly.windGust, units),
|
||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createDailyWeatherFeedItem(
|
||||
daily: DailyForecast,
|
||||
index: number,
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): DailyWeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.daily,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastDate: new Date(daily.forecastStart),
|
||||
conditionCode: daily.conditionCode,
|
||||
maxUvIndex: daily.maxUvIndex,
|
||||
precipitationAmount: convertPrecipitation(daily.precipitationAmount, units),
|
||||
precipitationChance: daily.precipitationChance,
|
||||
precipitationType: daily.precipitationType,
|
||||
snowfallAmount: convertPrecipitation(daily.snowfallAmount, units),
|
||||
sunrise: new Date(daily.sunrise),
|
||||
sunset: new Date(daily.sunset),
|
||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
||||
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
||||
|
||||
return {
|
||||
id: `weather-alert-${alert.id}`,
|
||||
type: WeatherFeedItemType.alert,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
alertId: alert.id,
|
||||
areaName: alert.areaName,
|
||||
certainty: alert.certainty,
|
||||
description: alert.description,
|
||||
detailsUrl: alert.detailsUrl,
|
||||
effectiveTime: new Date(alert.effectiveTime),
|
||||
expireTime: new Date(alert.expireTime),
|
||||
severity: alert.severity,
|
||||
source: alert.source,
|
||||
urgency: alert.urgency,
|
||||
},
|
||||
}
|
||||
}
|
||||
97
packages/aris-data-source-weatherkit/src/feed-items.ts
Normal file
97
packages/aris-data-source-weatherkit/src/feed-items.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { FeedItem } from "@aris/core"
|
||||
|
||||
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
||||
|
||||
export const WeatherFeedItemType = {
|
||||
current: "weather-current",
|
||||
hourly: "weather-hourly",
|
||||
daily: "weather-daily",
|
||||
alert: "weather-alert",
|
||||
} as const
|
||||
|
||||
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
||||
|
||||
export type CurrentWeatherData = {
|
||||
conditionCode: ConditionCode
|
||||
daylight: boolean
|
||||
humidity: number
|
||||
precipitationIntensity: number
|
||||
pressure: number
|
||||
pressureTrend: "rising" | "falling" | "steady"
|
||||
temperature: number
|
||||
temperatureApparent: number
|
||||
uvIndex: number
|
||||
visibility: number
|
||||
windDirection: number
|
||||
windGust: number
|
||||
windSpeed: number
|
||||
}
|
||||
|
||||
export interface CurrentWeatherFeedItem extends FeedItem<
|
||||
typeof WeatherFeedItemType.current,
|
||||
CurrentWeatherData
|
||||
> {}
|
||||
|
||||
export type HourlyWeatherData = {
|
||||
forecastTime: Date
|
||||
conditionCode: ConditionCode
|
||||
daylight: boolean
|
||||
humidity: number
|
||||
precipitationAmount: number
|
||||
precipitationChance: number
|
||||
precipitationType: PrecipitationType
|
||||
temperature: number
|
||||
temperatureApparent: number
|
||||
uvIndex: number
|
||||
windDirection: number
|
||||
windGust: number
|
||||
windSpeed: number
|
||||
}
|
||||
|
||||
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||
typeof WeatherFeedItemType.hourly,
|
||||
HourlyWeatherData
|
||||
> {}
|
||||
|
||||
export type DailyWeatherData = {
|
||||
forecastDate: Date
|
||||
conditionCode: ConditionCode
|
||||
maxUvIndex: number
|
||||
precipitationAmount: number
|
||||
precipitationChance: number
|
||||
precipitationType: PrecipitationType
|
||||
snowfallAmount: number
|
||||
sunrise: Date
|
||||
sunset: Date
|
||||
temperatureMax: number
|
||||
temperatureMin: number
|
||||
}
|
||||
|
||||
export interface DailyWeatherFeedItem extends FeedItem<
|
||||
typeof WeatherFeedItemType.daily,
|
||||
DailyWeatherData
|
||||
> {}
|
||||
|
||||
export type WeatherAlertData = {
|
||||
alertId: string
|
||||
areaName: string
|
||||
certainty: Certainty
|
||||
description: string
|
||||
detailsUrl: string
|
||||
effectiveTime: Date
|
||||
expireTime: Date
|
||||
severity: Severity
|
||||
source: string
|
||||
urgency: Urgency
|
||||
}
|
||||
|
||||
export interface WeatherAlertFeedItem extends FeedItem<
|
||||
typeof WeatherFeedItemType.alert,
|
||||
WeatherAlertData
|
||||
> {}
|
||||
|
||||
export type WeatherFeedItem =
|
||||
| CurrentWeatherFeedItem
|
||||
| HourlyWeatherFeedItem
|
||||
| DailyWeatherFeedItem
|
||||
| WeatherAlertFeedItem
|
||||
38
packages/aris-data-source-weatherkit/src/index.ts
Normal file
38
packages/aris-data-source-weatherkit/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export {
|
||||
WeatherKitDataSource,
|
||||
Units,
|
||||
type Units as UnitsType,
|
||||
type WeatherKitDataSourceOptions,
|
||||
type WeatherKitQueryConfig,
|
||||
} from "./data-source"
|
||||
|
||||
export {
|
||||
WeatherFeedItemType,
|
||||
type WeatherFeedItemType as WeatherFeedItemTypeType,
|
||||
type CurrentWeatherData,
|
||||
type CurrentWeatherFeedItem,
|
||||
type DailyWeatherData,
|
||||
type DailyWeatherFeedItem,
|
||||
type HourlyWeatherData,
|
||||
type HourlyWeatherFeedItem,
|
||||
type WeatherAlertData,
|
||||
type WeatherAlertFeedItem,
|
||||
type WeatherFeedItem,
|
||||
} from "./feed-items"
|
||||
|
||||
export {
|
||||
Severity,
|
||||
Urgency,
|
||||
Certainty,
|
||||
PrecipitationType,
|
||||
ConditionCode,
|
||||
DefaultWeatherKitClient,
|
||||
type Severity as SeverityType,
|
||||
type Urgency as UrgencyType,
|
||||
type Certainty as CertaintyType,
|
||||
type PrecipitationType as PrecipitationTypeType,
|
||||
type ConditionCode as ConditionCodeType,
|
||||
type WeatherKitCredentials,
|
||||
type WeatherKitClient,
|
||||
type WeatherKitQueryOptions,
|
||||
} from "./weatherkit"
|
||||
367
packages/aris-data-source-weatherkit/src/weatherkit.ts
Normal file
367
packages/aris-data-source-weatherkit/src/weatherkit.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
// WeatherKit REST API client and response types
|
||||
// https://developer.apple.com/documentation/weatherkitrestapi
|
||||
|
||||
import { type } from "arktype"
|
||||
|
||||
export interface WeatherKitCredentials {
|
||||
privateKey: string
|
||||
keyId: string
|
||||
teamId: string
|
||||
serviceId: string
|
||||
}
|
||||
|
||||
export interface WeatherKitQueryOptions {
|
||||
lat: number
|
||||
lng: number
|
||||
language?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface WeatherKitClient {
|
||||
fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse>
|
||||
}
|
||||
|
||||
export class DefaultWeatherKitClient implements WeatherKitClient {
|
||||
private readonly credentials: WeatherKitCredentials
|
||||
|
||||
constructor(credentials: WeatherKitCredentials) {
|
||||
this.credentials = credentials
|
||||
}
|
||||
|
||||
async fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse> {
|
||||
const token = await generateJwt(this.credentials)
|
||||
|
||||
const dataSets = ["currentWeather", "forecastHourly", "forecastDaily", "weatherAlerts"].join(
|
||||
",",
|
||||
)
|
||||
|
||||
const url = new URL(
|
||||
`${WEATHERKIT_API_BASE}/weather/${query.language ?? "en"}/${query.lat}/${query.lng}`,
|
||||
)
|
||||
url.searchParams.set("dataSets", dataSets)
|
||||
if (query.timezone) {
|
||||
url.searchParams.set("timezone", query.timezone)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(`WeatherKit API error: ${response.status} ${response.statusText}: ${body}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const result = weatherKitResponseSchema(json)
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
throw new Error(`WeatherKit API response validation failed: ${result.summary}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const Severity = {
|
||||
Minor: "minor",
|
||||
Moderate: "moderate",
|
||||
Severe: "severe",
|
||||
Extreme: "extreme",
|
||||
} as const
|
||||
|
||||
export type Severity = (typeof Severity)[keyof typeof Severity]
|
||||
|
||||
export const Urgency = {
|
||||
Immediate: "immediate",
|
||||
Expected: "expected",
|
||||
Future: "future",
|
||||
Past: "past",
|
||||
Unknown: "unknown",
|
||||
} as const
|
||||
|
||||
export type Urgency = (typeof Urgency)[keyof typeof Urgency]
|
||||
|
||||
export const Certainty = {
|
||||
Observed: "observed",
|
||||
Likely: "likely",
|
||||
Possible: "possible",
|
||||
Unlikely: "unlikely",
|
||||
Unknown: "unknown",
|
||||
} as const
|
||||
|
||||
export type Certainty = (typeof Certainty)[keyof typeof Certainty]
|
||||
|
||||
export const PrecipitationType = {
|
||||
Clear: "clear",
|
||||
Precipitation: "precipitation",
|
||||
Rain: "rain",
|
||||
Snow: "snow",
|
||||
Sleet: "sleet",
|
||||
Hail: "hail",
|
||||
Mixed: "mixed",
|
||||
} as const
|
||||
|
||||
export type PrecipitationType = (typeof PrecipitationType)[keyof typeof PrecipitationType]
|
||||
|
||||
export const ConditionCode = {
|
||||
Clear: "Clear",
|
||||
Cloudy: "Cloudy",
|
||||
Dust: "Dust",
|
||||
Fog: "Fog",
|
||||
Haze: "Haze",
|
||||
MostlyClear: "MostlyClear",
|
||||
MostlyCloudy: "MostlyCloudy",
|
||||
PartlyCloudy: "PartlyCloudy",
|
||||
ScatteredThunderstorms: "ScatteredThunderstorms",
|
||||
Smoke: "Smoke",
|
||||
Breezy: "Breezy",
|
||||
Windy: "Windy",
|
||||
Drizzle: "Drizzle",
|
||||
HeavyRain: "HeavyRain",
|
||||
Rain: "Rain",
|
||||
Showers: "Showers",
|
||||
Flurries: "Flurries",
|
||||
HeavySnow: "HeavySnow",
|
||||
MixedRainAndSleet: "MixedRainAndSleet",
|
||||
MixedRainAndSnow: "MixedRainAndSnow",
|
||||
MixedRainfall: "MixedRainfall",
|
||||
MixedSnowAndSleet: "MixedSnowAndSleet",
|
||||
ScatteredShowers: "ScatteredShowers",
|
||||
ScatteredSnowShowers: "ScatteredSnowShowers",
|
||||
Sleet: "Sleet",
|
||||
Snow: "Snow",
|
||||
SnowShowers: "SnowShowers",
|
||||
Blizzard: "Blizzard",
|
||||
BlowingSnow: "BlowingSnow",
|
||||
FreezingDrizzle: "FreezingDrizzle",
|
||||
FreezingRain: "FreezingRain",
|
||||
Frigid: "Frigid",
|
||||
Hail: "Hail",
|
||||
Hot: "Hot",
|
||||
Hurricane: "Hurricane",
|
||||
IsolatedThunderstorms: "IsolatedThunderstorms",
|
||||
SevereThunderstorm: "SevereThunderstorm",
|
||||
Thunderstorm: "Thunderstorm",
|
||||
Tornado: "Tornado",
|
||||
TropicalStorm: "TropicalStorm",
|
||||
} as const
|
||||
|
||||
export type ConditionCode = (typeof ConditionCode)[keyof typeof ConditionCode]
|
||||
|
||||
const WEATHERKIT_API_BASE = "https://weatherkit.apple.com/api/v1"
|
||||
|
||||
const severitySchema = type.enumerated(
|
||||
Severity.Minor,
|
||||
Severity.Moderate,
|
||||
Severity.Severe,
|
||||
Severity.Extreme,
|
||||
)
|
||||
|
||||
const urgencySchema = type.enumerated(
|
||||
Urgency.Immediate,
|
||||
Urgency.Expected,
|
||||
Urgency.Future,
|
||||
Urgency.Past,
|
||||
Urgency.Unknown,
|
||||
)
|
||||
|
||||
const certaintySchema = type.enumerated(
|
||||
Certainty.Observed,
|
||||
Certainty.Likely,
|
||||
Certainty.Possible,
|
||||
Certainty.Unlikely,
|
||||
Certainty.Unknown,
|
||||
)
|
||||
|
||||
const precipitationTypeSchema = type.enumerated(
|
||||
PrecipitationType.Clear,
|
||||
PrecipitationType.Precipitation,
|
||||
PrecipitationType.Rain,
|
||||
PrecipitationType.Snow,
|
||||
PrecipitationType.Sleet,
|
||||
PrecipitationType.Hail,
|
||||
PrecipitationType.Mixed,
|
||||
)
|
||||
|
||||
const conditionCodeSchema = type.enumerated(...Object.values(ConditionCode))
|
||||
|
||||
const pressureTrendSchema = type.enumerated("rising", "falling", "steady")
|
||||
|
||||
const currentWeatherSchema = type({
|
||||
asOf: "string",
|
||||
conditionCode: conditionCodeSchema,
|
||||
daylight: "boolean",
|
||||
humidity: "number",
|
||||
precipitationIntensity: "number",
|
||||
pressure: "number",
|
||||
pressureTrend: pressureTrendSchema,
|
||||
temperature: "number",
|
||||
temperatureApparent: "number",
|
||||
temperatureDewPoint: "number",
|
||||
uvIndex: "number",
|
||||
visibility: "number",
|
||||
windDirection: "number",
|
||||
windGust: "number",
|
||||
windSpeed: "number",
|
||||
})
|
||||
|
||||
export type CurrentWeather = typeof currentWeatherSchema.infer
|
||||
|
||||
const hourlyForecastSchema = type({
|
||||
forecastStart: "string",
|
||||
conditionCode: conditionCodeSchema,
|
||||
daylight: "boolean",
|
||||
humidity: "number",
|
||||
precipitationAmount: "number",
|
||||
precipitationChance: "number",
|
||||
precipitationType: precipitationTypeSchema,
|
||||
pressure: "number",
|
||||
snowfallIntensity: "number",
|
||||
temperature: "number",
|
||||
temperatureApparent: "number",
|
||||
temperatureDewPoint: "number",
|
||||
uvIndex: "number",
|
||||
visibility: "number",
|
||||
windDirection: "number",
|
||||
windGust: "number",
|
||||
windSpeed: "number",
|
||||
})
|
||||
|
||||
export type HourlyForecast = typeof hourlyForecastSchema.infer
|
||||
|
||||
const dayWeatherConditionsSchema = type({
|
||||
conditionCode: conditionCodeSchema,
|
||||
humidity: "number",
|
||||
precipitationAmount: "number",
|
||||
precipitationChance: "number",
|
||||
precipitationType: precipitationTypeSchema,
|
||||
snowfallAmount: "number",
|
||||
temperatureMax: "number",
|
||||
temperatureMin: "number",
|
||||
windDirection: "number",
|
||||
"windGust?": "number",
|
||||
windSpeed: "number",
|
||||
})
|
||||
|
||||
export type DayWeatherConditions = typeof dayWeatherConditionsSchema.infer
|
||||
|
||||
const dailyForecastSchema = type({
|
||||
forecastStart: "string",
|
||||
forecastEnd: "string",
|
||||
conditionCode: conditionCodeSchema,
|
||||
maxUvIndex: "number",
|
||||
moonPhase: "string",
|
||||
"moonrise?": "string",
|
||||
"moonset?": "string",
|
||||
precipitationAmount: "number",
|
||||
precipitationChance: "number",
|
||||
precipitationType: precipitationTypeSchema,
|
||||
snowfallAmount: "number",
|
||||
sunrise: "string",
|
||||
sunriseCivil: "string",
|
||||
sunriseNautical: "string",
|
||||
sunriseAstronomical: "string",
|
||||
sunset: "string",
|
||||
sunsetCivil: "string",
|
||||
sunsetNautical: "string",
|
||||
sunsetAstronomical: "string",
|
||||
temperatureMax: "number",
|
||||
temperatureMin: "number",
|
||||
"daytimeForecast?": dayWeatherConditionsSchema,
|
||||
"overnightForecast?": dayWeatherConditionsSchema,
|
||||
})
|
||||
|
||||
export type DailyForecast = typeof dailyForecastSchema.infer
|
||||
|
||||
const weatherAlertSchema = type({
|
||||
id: "string",
|
||||
areaId: "string",
|
||||
areaName: "string",
|
||||
certainty: certaintySchema,
|
||||
countryCode: "string",
|
||||
description: "string",
|
||||
detailsUrl: "string",
|
||||
effectiveTime: "string",
|
||||
expireTime: "string",
|
||||
issuedTime: "string",
|
||||
responses: "string[]",
|
||||
severity: severitySchema,
|
||||
source: "string",
|
||||
urgency: urgencySchema,
|
||||
})
|
||||
|
||||
export type WeatherAlert = typeof weatherAlertSchema.infer
|
||||
|
||||
const weatherKitResponseSchema = type({
|
||||
"currentWeather?": currentWeatherSchema,
|
||||
"forecastHourly?": type({
|
||||
hours: hourlyForecastSchema.array(),
|
||||
}),
|
||||
"forecastDaily?": type({
|
||||
days: dailyForecastSchema.array(),
|
||||
}),
|
||||
"weatherAlerts?": type({
|
||||
alerts: weatherAlertSchema.array(),
|
||||
}),
|
||||
})
|
||||
|
||||
export type WeatherKitResponse = typeof weatherKitResponseSchema.infer
|
||||
|
||||
async function generateJwt(credentials: WeatherKitCredentials): Promise<string> {
|
||||
const header = {
|
||||
alg: "ES256",
|
||||
kid: credentials.keyId,
|
||||
id: `${credentials.teamId}.${credentials.serviceId}`,
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const payload = {
|
||||
iss: credentials.teamId,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
sub: credentials.serviceId,
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const headerB64 = btoa(JSON.stringify(header))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "")
|
||||
const payloadB64 = btoa(JSON.stringify(payload))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "")
|
||||
|
||||
const signingInput = `${headerB64}.${payloadB64}`
|
||||
|
||||
const pemContents = credentials.privateKey
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/, "")
|
||||
.replace(/-----END PRIVATE KEY-----/, "")
|
||||
.replace(/\s/g, "")
|
||||
|
||||
const binaryKey = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0))
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
binaryKey,
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["sign"],
|
||||
)
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
cryptoKey,
|
||||
encoder.encode(signingInput),
|
||||
)
|
||||
|
||||
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "")
|
||||
|
||||
return `${signingInput}.${signatureB64}`
|
||||
}
|
||||
Reference in New Issue
Block a user