@nyc-transit-kit/soda3 0.1.4 → 0.3.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/README.md +38 -21
- package/package.json +13 -17
- package/src/Dataset.ts +72 -0
- package/src/Discovery.ts +107 -0
- package/src/Socrata.ts +115 -0
- package/src/Soda3Client.ts +224 -0
- package/src/Soql.ts +200 -0
- package/src/index.ts +5 -61
- package/src/catalog.ts +0 -28
- package/src/client.ts +0 -17
- package/src/endpoints.ts +0 -110
- package/src/errors.ts +0 -58
- package/src/export.ts +0 -41
- package/src/internal/bodies.ts +0 -23
- package/src/internal/http.ts +0 -42
- package/src/internal/responses.ts +0 -56
- package/src/internal/services.ts +0 -48
- package/src/internal/transport.ts +0 -99
- package/src/metadata.ts +0 -47
- package/src/query.ts +0 -132
- package/src/soql.ts +0 -353
package/README.md
CHANGED
|
@@ -1,30 +1,47 @@
|
|
|
1
1
|
# @nyc-transit-kit/soda3
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
catalog
|
|
3
|
+
Effect client for Socrata on any domain: SODA3 query and CSV export, Discovery
|
|
4
|
+
catalog search, and SoQL built as `effect/sql` Statement fragments whose values
|
|
5
|
+
are written as SoQL literals. It speaks SODA3 alone; the legacy row endpoints
|
|
6
|
+
are not supported.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
## Import Paths
|
|
8
|
+
```sh
|
|
9
|
+
bun add effect@^4.0.6 @nyc-transit-kit/soda3
|
|
10
|
+
```
|
|
10
11
|
|
|
11
12
|
```ts
|
|
12
|
-
import
|
|
13
|
-
import
|
|
13
|
+
import * as Socrata from "@nyc-transit-kit/soda3/Socrata"
|
|
14
|
+
import * as Soda3Client from "@nyc-transit-kit/soda3/Soda3Client"
|
|
15
|
+
import * as Soql from "@nyc-transit-kit/soda3/Soql"
|
|
14
16
|
import * as Effect from "effect/Effect"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient"
|
|
18
|
+
import * as Layer from "effect/Layer"
|
|
19
|
+
import * as Redacted from "effect/Redacted"
|
|
20
|
+
|
|
21
|
+
const rows = Effect.gen(function* () {
|
|
22
|
+
const soda = yield* Soda3Client.Soda3Client
|
|
23
|
+
return yield* soda.query({
|
|
24
|
+
domain: Socrata.SocrataDomain.make("data.ny.gov"),
|
|
25
|
+
datasetId: Socrata.SocrataDatasetId.make("f462-ka72"),
|
|
26
|
+
query: Soql.soql`SELECT dataset_title, status WHERE ${Soql.eq("agency", "New York City Transit")} LIMIT 5`
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const program = rows.pipe(
|
|
31
|
+
Effect.provide(
|
|
32
|
+
Soda3Client.layer({ appToken: Redacted.make("your-app-token") }).pipe(
|
|
33
|
+
Layer.provide(FetchHttpClient.layer)
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
)
|
|
21
37
|
```
|
|
22
38
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
- SODA3 refuses query and export without an app token, so
|
|
40
|
+
`Soda3Client.layer` requires one. `Discovery.search` needs none.
|
|
41
|
+
- Refusals fail with `SocrataError`, which keeps Socrata's own sentence, such
|
|
42
|
+
as "This request must be authenticated or have an application token".
|
|
43
|
+
- Nothing retries or times out on its own; compose `HttpClient.retryTransient`
|
|
44
|
+
and `Effect.timeout` where you run it.
|
|
45
|
+
- Modules: `/Soda3Client`, `/Dataset`, `/Discovery`, `/Soql`, `/Socrata`.
|
|
28
46
|
|
|
29
|
-
|
|
30
|
-
version documented in `docs/getting-started.md`.
|
|
47
|
+
See the [API reference](https://github.com/mannyc2/nyc-transit-kit/blob/main/docs/api-reference.md).
|
package/package.json
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyc-transit-kit/soda3",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
|
-
"./
|
|
10
|
-
"./
|
|
11
|
-
"./
|
|
12
|
-
"./
|
|
13
|
-
"./
|
|
14
|
-
"./metadata": "./src/metadata.ts",
|
|
15
|
-
"./query": "./src/query.ts",
|
|
16
|
-
"./soql": "./src/soql.ts"
|
|
9
|
+
"./Dataset": "./src/Dataset.ts",
|
|
10
|
+
"./Discovery": "./src/Discovery.ts",
|
|
11
|
+
"./Socrata": "./src/Socrata.ts",
|
|
12
|
+
"./Soda3Client": "./src/Soda3Client.ts",
|
|
13
|
+
"./Soql": "./src/Soql.ts"
|
|
17
14
|
},
|
|
18
15
|
"types": "./src/index.ts",
|
|
19
16
|
"files": [
|
|
@@ -21,17 +18,16 @@
|
|
|
21
18
|
"README.md",
|
|
22
19
|
"src"
|
|
23
20
|
],
|
|
24
|
-
"scripts": {
|
|
25
|
-
"build": "tsc -b",
|
|
26
|
-
"test": "bun test ./test"
|
|
27
|
-
},
|
|
28
|
-
"dependencies": {
|
|
29
|
-
"@nyc-transit-kit/contracts": "0.1.4",
|
|
30
|
-
"effect": "4.0.0-beta.92"
|
|
31
|
-
},
|
|
32
21
|
"publishConfig": {
|
|
33
22
|
"access": "public"
|
|
34
23
|
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"effect": "^4.0.6"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@nyc-transit-kit/fixtures": "0.3.0",
|
|
29
|
+
"effect": "4.0.6"
|
|
30
|
+
},
|
|
35
31
|
"repository": {
|
|
36
32
|
"type": "git",
|
|
37
33
|
"url": "git+https://github.com/mannyc2/nyc-transit-kit.git",
|
package/src/Dataset.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated datasets: a Socrata dataset together with the schema of one full row,
|
|
3
|
+
* queried and decoded as typed rows.
|
|
4
|
+
*/
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as Schema from "effect/Schema"
|
|
7
|
+
import * as Stream from "effect/Stream"
|
|
8
|
+
import type { DatasetRef } from "./Socrata"
|
|
9
|
+
import * as Soda3Client from "./Soda3Client"
|
|
10
|
+
import * as Soql from "./Soql"
|
|
11
|
+
|
|
12
|
+
/** A Socrata dataset and the schema of one full row. */
|
|
13
|
+
export interface Dataset<
|
|
14
|
+
Row extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>
|
|
15
|
+
> extends DatasetRef {
|
|
16
|
+
readonly title: string
|
|
17
|
+
readonly description: string
|
|
18
|
+
readonly row: Row
|
|
19
|
+
/** The column that orders rows in time, when there is one. `stream` pages in its order. */
|
|
20
|
+
readonly timestampColumn?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Typed rows. A row schema describes whole rows, so typed queries always
|
|
25
|
+
* `SELECT *`; use `Soda3Client` for projections and aggregates.
|
|
26
|
+
*/
|
|
27
|
+
export const query = Effect.fn("Dataset.query")(function* <
|
|
28
|
+
Row extends Schema.Codec<unknown, unknown>
|
|
29
|
+
>(dataset: Dataset<Row>, clauses: Soql.Clauses = {}) {
|
|
30
|
+
const soda = yield* Soda3Client.Soda3Client
|
|
31
|
+
const rows = yield* soda.query({
|
|
32
|
+
domain: dataset.domain,
|
|
33
|
+
datasetId: dataset.datasetId,
|
|
34
|
+
query: Soql.selectAll(clauses)
|
|
35
|
+
})
|
|
36
|
+
return yield* Schema.decodeUnknownEffect(Schema.Array(dataset.row))(rows)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const oldestFirst = (dataset: Pick<Dataset, "timestampColumn">): Soql.OrderBy | undefined =>
|
|
40
|
+
dataset.timestampColumn === undefined ? undefined : { column: dataset.timestampColumn }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every matching row, one page per request, decoded as it arrives. Unless
|
|
44
|
+
* `clauses` give an `orderBy`, rows come oldest first by the dataset's
|
|
45
|
+
* `timestampColumn`, when it has one, so rows added while you page land after
|
|
46
|
+
* the pages already read. Unordered, SODA3 served Traffic Speeds newest first
|
|
47
|
+
* and page 2 repeated page 1; see `Soda3Client.streamRows`.
|
|
48
|
+
*/
|
|
49
|
+
export const stream = <Row extends Schema.Codec<unknown, unknown>>(
|
|
50
|
+
dataset: Dataset<Row>,
|
|
51
|
+
clauses: Omit<Soql.Clauses, "limit"> = {},
|
|
52
|
+
pageSize?: number
|
|
53
|
+
) =>
|
|
54
|
+
Soda3Client.streamRows(
|
|
55
|
+
{
|
|
56
|
+
domain: dataset.domain,
|
|
57
|
+
datasetId: dataset.datasetId,
|
|
58
|
+
query: Soql.selectAll({ ...clauses, orderBy: clauses.orderBy ?? oldestFirst(dataset) })
|
|
59
|
+
},
|
|
60
|
+
pageSize
|
|
61
|
+
).pipe(Stream.mapEffect((row) => Schema.decodeUnknownEffect(dataset.row)(row)))
|
|
62
|
+
|
|
63
|
+
/** The dataset's SODA3 export as CSV bytes, narrowed by `body.query` when given. */
|
|
64
|
+
export const exportBytes = (
|
|
65
|
+
dataset: DatasetRef,
|
|
66
|
+
body: Omit<Soda3Client.ExportRequest, "domain" | "datasetId"> = {}
|
|
67
|
+
) =>
|
|
68
|
+
Stream.service(Soda3Client.Soda3Client).pipe(
|
|
69
|
+
Stream.flatMap((soda) =>
|
|
70
|
+
soda.export({ ...body, domain: dataset.domain, datasetId: dataset.datasetId })
|
|
71
|
+
)
|
|
72
|
+
)
|
package/src/Discovery.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Socrata Discovery API: catalog search and dataset metadata. It needs no
|
|
3
|
+
* app token.
|
|
4
|
+
*/
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as HttpClient from "effect/http/HttpClient"
|
|
7
|
+
import * as HttpClientResponse from "effect/http/HttpClientResponse"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import { type DatasetRef, filterStatusOk, SocrataDatasetId, SocrataDomain } from "./Socrata"
|
|
10
|
+
|
|
11
|
+
export const url = "https://api.us.socrata.com/api/catalog/v1"
|
|
12
|
+
|
|
13
|
+
const Resource = Schema.Struct({
|
|
14
|
+
id: SocrataDatasetId,
|
|
15
|
+
name: Schema.String,
|
|
16
|
+
description: Schema.String,
|
|
17
|
+
type: Schema.String,
|
|
18
|
+
attribution: Schema.NullOr(Schema.String),
|
|
19
|
+
updatedAt: Schema.String,
|
|
20
|
+
createdAt: Schema.String,
|
|
21
|
+
data_updated_at: Schema.String,
|
|
22
|
+
metadata_updated_at: Schema.String,
|
|
23
|
+
columns_name: Schema.Array(Schema.String),
|
|
24
|
+
columns_field_name: Schema.Array(Schema.String),
|
|
25
|
+
columns_datatype: Schema.Array(Schema.String),
|
|
26
|
+
columns_description: Schema.Array(Schema.String)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const CatalogResult = Schema.Struct({
|
|
30
|
+
resource: Resource,
|
|
31
|
+
classification: Schema.Struct({
|
|
32
|
+
categories: Schema.Array(Schema.String),
|
|
33
|
+
tags: Schema.Array(Schema.String),
|
|
34
|
+
domain_category: Schema.optionalKey(Schema.String),
|
|
35
|
+
domain_tags: Schema.Array(Schema.String)
|
|
36
|
+
}),
|
|
37
|
+
metadata: Schema.Struct({ domain: SocrataDomain }),
|
|
38
|
+
permalink: Schema.String,
|
|
39
|
+
link: Schema.String
|
|
40
|
+
})
|
|
41
|
+
export type CatalogResult = typeof CatalogResult.Type
|
|
42
|
+
|
|
43
|
+
/** Response of `GET https://api.us.socrata.com/api/catalog/v1`. */
|
|
44
|
+
export const SearchResponse = Schema.Struct({
|
|
45
|
+
results: Schema.Array(CatalogResult),
|
|
46
|
+
resultSetSize: Schema.Int
|
|
47
|
+
})
|
|
48
|
+
export type SearchResponse = typeof SearchResponse.Type
|
|
49
|
+
|
|
50
|
+
export class DatasetNotFound extends Schema.TaggedError<DatasetNotFound>()("DatasetNotFound", {
|
|
51
|
+
domain: SocrataDomain,
|
|
52
|
+
datasetId: SocrataDatasetId
|
|
53
|
+
}) {
|
|
54
|
+
override get message() {
|
|
55
|
+
return `${this.domain} has no public dataset ${this.datasetId}`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Search {
|
|
60
|
+
readonly domain: SocrataDomain
|
|
61
|
+
readonly query?: string | undefined
|
|
62
|
+
readonly ids?: ReadonlyArray<SocrataDatasetId> | undefined
|
|
63
|
+
readonly categories?: ReadonlyArray<string> | undefined
|
|
64
|
+
readonly tags?: ReadonlyArray<string> | undefined
|
|
65
|
+
readonly order?: string | undefined
|
|
66
|
+
readonly limit?: number | undefined
|
|
67
|
+
readonly offset?: number | undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Searches the public datasets of one domain. */
|
|
71
|
+
export const search = Effect.fn("Discovery.search")(function* (search: Search) {
|
|
72
|
+
const client = yield* HttpClient.HttpClient
|
|
73
|
+
const response = yield* client
|
|
74
|
+
.get(url, {
|
|
75
|
+
acceptJson: true,
|
|
76
|
+
urlParams: {
|
|
77
|
+
domains: search.domain,
|
|
78
|
+
search_context: search.domain,
|
|
79
|
+
only: "dataset",
|
|
80
|
+
q: search.query,
|
|
81
|
+
ids: search.ids,
|
|
82
|
+
categories: search.categories,
|
|
83
|
+
tags: search.tags,
|
|
84
|
+
order: search.order,
|
|
85
|
+
limit: search.limit,
|
|
86
|
+
offset: search.offset
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
.pipe(Effect.flatMap(filterStatusOk))
|
|
90
|
+
return yield* HttpClientResponse.schemaBodyJson(SearchResponse)(response)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/** The Discovery entry of one dataset. */
|
|
94
|
+
export const metadata = Effect.fn("Discovery.metadata")(function* (ref: DatasetRef) {
|
|
95
|
+
const { results } = yield* search({ domain: ref.domain, ids: [ref.datasetId] })
|
|
96
|
+
const result = results.find((candidate) => candidate.resource.id === ref.datasetId)
|
|
97
|
+
return result ?? (yield* new DatasetNotFound({ domain: ref.domain, datasetId: ref.datasetId }))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/** Discovery lists columns as four parallel arrays; this zips them. */
|
|
101
|
+
export const columns = ({ resource }: CatalogResult) =>
|
|
102
|
+
resource.columns_field_name.map((field, index) => ({
|
|
103
|
+
field,
|
|
104
|
+
name: resource.columns_name[index] ?? field,
|
|
105
|
+
datatype: resource.columns_datatype[index] ?? "",
|
|
106
|
+
description: resource.columns_description[index] ?? ""
|
|
107
|
+
}))
|
package/src/Socrata.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every Socrata API shares: domains, dataset ids, the floating timestamps
|
|
3
|
+
* in rows, and the error Socrata answers with.
|
|
4
|
+
*/
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as HttpClientResponse from "effect/http/HttpClientResponse"
|
|
7
|
+
import * as Option from "effect/Option"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
|
|
10
|
+
/** A Socrata domain without protocol or path, such as `data.cityofnewyork.us`. */
|
|
11
|
+
export const SocrataDomain = Schema.String.pipe(
|
|
12
|
+
Schema.check(
|
|
13
|
+
Schema.isPattern(/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/, {
|
|
14
|
+
message: "Expected a Socrata domain without a protocol or path"
|
|
15
|
+
})
|
|
16
|
+
),
|
|
17
|
+
Schema.brand("SocrataDomain")
|
|
18
|
+
)
|
|
19
|
+
export type SocrataDomain = typeof SocrataDomain.Type
|
|
20
|
+
|
|
21
|
+
/** The eight-character id of a dataset, such as `i4gi-tjb9`. */
|
|
22
|
+
export const SocrataDatasetId = Schema.String.pipe(
|
|
23
|
+
Schema.check(
|
|
24
|
+
Schema.isPattern(/^[a-z0-9]{4}-[a-z0-9]{4}$/, {
|
|
25
|
+
message: "Expected an eight-character Socrata dataset id such as abcd-1234"
|
|
26
|
+
})
|
|
27
|
+
),
|
|
28
|
+
Schema.brand("SocrataDatasetId")
|
|
29
|
+
)
|
|
30
|
+
export type SocrataDatasetId = typeof SocrataDatasetId.Type
|
|
31
|
+
|
|
32
|
+
/** One dataset on one domain. */
|
|
33
|
+
export interface DatasetRef {
|
|
34
|
+
readonly domain: SocrataDomain
|
|
35
|
+
readonly datasetId: SocrataDatasetId
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The dataset's page, `https://{domain}/d/{id}`. */
|
|
39
|
+
export const datasetUrl = (ref: DatasetRef) => `https://${ref.domain}/d/${ref.datasetId}`
|
|
40
|
+
|
|
41
|
+
const isCalendarDate = (value: string) => {
|
|
42
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
|
43
|
+
if (match === null) {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
47
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
48
|
+
return (
|
|
49
|
+
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A calendar date, `YYYY-MM-DD`. */
|
|
54
|
+
export const IsoDate = Schema.String.pipe(
|
|
55
|
+
Schema.check(
|
|
56
|
+
Schema.makeFilter<string>(
|
|
57
|
+
(value) => isCalendarDate(value) || "Expected an ISO calendar date in YYYY-MM-DD format"
|
|
58
|
+
)
|
|
59
|
+
),
|
|
60
|
+
Schema.brand("IsoDate")
|
|
61
|
+
)
|
|
62
|
+
export type IsoDate = typeof IsoDate.Type
|
|
63
|
+
|
|
64
|
+
/** Floating timestamps carry no zone: `2026-10-26T23:02:06.000` is New York local time. */
|
|
65
|
+
export const FloatingTimestamp = Schema.String.check(
|
|
66
|
+
Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/, {
|
|
67
|
+
message: "Expected a Socrata floating timestamp such as 2026-10-26T23:02:06.000"
|
|
68
|
+
})
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
export const MultiLineString = Schema.Struct({
|
|
72
|
+
type: Schema.Literal("MultiLineString"),
|
|
73
|
+
coordinates: Schema.Array(Schema.Array(Schema.Array(Schema.Finite)))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
/** Socrata answered with a non-2xx status. `message` is Socrata's own sentence when it sent one. */
|
|
77
|
+
export class SocrataError extends Schema.TaggedError<SocrataError>()("SocrataError", {
|
|
78
|
+
status: Schema.Int,
|
|
79
|
+
code: Schema.optional(Schema.String),
|
|
80
|
+
message: Schema.String
|
|
81
|
+
}) {}
|
|
82
|
+
|
|
83
|
+
/** Non-2xx bodies: SODA3 sends `{ code, error: true, message }`, Discovery sends `{ error }`. */
|
|
84
|
+
export const SocrataErrorBody = Schema.Union([
|
|
85
|
+
Schema.Struct({ code: Schema.optionalKey(Schema.String), message: Schema.String }),
|
|
86
|
+
Schema.Struct({ error: Schema.String })
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
const decodeErrorBody = Schema.decodeUnknownOption(Schema.fromJsonString(SocrataErrorBody))
|
|
90
|
+
|
|
91
|
+
const socrataError = (status: number, text: string) =>
|
|
92
|
+
Option.match(decodeErrorBody(text), {
|
|
93
|
+
onNone: () => new SocrataError({ status, message: `Socrata answered HTTP ${status}` }),
|
|
94
|
+
onSome: (body) =>
|
|
95
|
+
"message" in body
|
|
96
|
+
? new SocrataError({ status, code: body.code, message: body.message })
|
|
97
|
+
: new SocrataError({ status, message: body.error })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Passes 2xx responses through and fails the rest with a `SocrataError`. Unlike
|
|
102
|
+
* `HttpClient.filterStatusOk` it reads the body, which holds the only useful
|
|
103
|
+
* sentence, such as "This request must be authenticated or have an application token".
|
|
104
|
+
*/
|
|
105
|
+
export const filterStatusOk = (
|
|
106
|
+
response: HttpClientResponse.HttpClientResponse
|
|
107
|
+
): Effect.Effect<HttpClientResponse.HttpClientResponse, SocrataError> =>
|
|
108
|
+
HttpClientResponse.matchStatus(response, {
|
|
109
|
+
"2xx": (ok) => Effect.succeed(ok),
|
|
110
|
+
orElse: (failed) =>
|
|
111
|
+
failed.text.pipe(
|
|
112
|
+
Effect.orElseSucceed(() => ""),
|
|
113
|
+
Effect.flatMap((text) => Effect.fail(socrataError(failed.status, text)))
|
|
114
|
+
)
|
|
115
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SODA3 `query.json` and `export.csv` on any Socrata domain.
|
|
3
|
+
*/
|
|
4
|
+
import * as Context from "effect/Context"
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as HttpClient from "effect/http/HttpClient"
|
|
7
|
+
import type * as HttpClientError from "effect/http/HttpClientError"
|
|
8
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest"
|
|
9
|
+
import * as HttpClientResponse from "effect/http/HttpClientResponse"
|
|
10
|
+
import * as Layer from "effect/Layer"
|
|
11
|
+
import * as Option from "effect/Option"
|
|
12
|
+
import * as Redacted from "effect/Redacted"
|
|
13
|
+
import * as Schema from "effect/Schema"
|
|
14
|
+
import * as Stream from "effect/Stream"
|
|
15
|
+
import * as Struct from "effect/Struct"
|
|
16
|
+
import { type DatasetRef, filterStatusOk, type SocrataError } from "./Socrata"
|
|
17
|
+
import * as Soql from "./Soql"
|
|
18
|
+
|
|
19
|
+
export const OrderingSpecifier = Schema.Literals(["total", "discard"])
|
|
20
|
+
export type OrderingSpecifier = typeof OrderingSpecifier.Type
|
|
21
|
+
|
|
22
|
+
export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
|
23
|
+
|
|
24
|
+
export const Page = Schema.Struct({ pageNumber: PositiveInt, pageSize: PositiveInt })
|
|
25
|
+
export type Page = typeof Page.Type
|
|
26
|
+
|
|
27
|
+
/** Only parameterized views read `parameters`; ordinary queries carry their values in the SoQL. */
|
|
28
|
+
export const Parameters = Schema.Record(
|
|
29
|
+
Schema.String,
|
|
30
|
+
Schema.Union([Schema.String, Schema.Finite, Schema.Boolean])
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
/** Body of `POST /api/v3/views/{id}/query.json`. */
|
|
34
|
+
export const QueryBody = Schema.Struct({
|
|
35
|
+
query: Schema.String,
|
|
36
|
+
page: Schema.optionalKey(Page),
|
|
37
|
+
parameters: Schema.optionalKey(Parameters),
|
|
38
|
+
timeout: Schema.optionalKey(PositiveInt),
|
|
39
|
+
includeSystem: Schema.optionalKey(Schema.Boolean),
|
|
40
|
+
includeSynthetic: Schema.optionalKey(Schema.Boolean),
|
|
41
|
+
orderingSpecifier: Schema.optionalKey(OrderingSpecifier)
|
|
42
|
+
})
|
|
43
|
+
export type QueryBody = typeof QueryBody.Type
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Body of `POST /api/v3/views/{id}/export.csv`. SODA3's documentation shapes
|
|
47
|
+
* the file with `serializationOptions`, such as `{ separator: "\t", bom: true }`.
|
|
48
|
+
*/
|
|
49
|
+
export const ExportBody = Schema.Struct({
|
|
50
|
+
query: Schema.optionalKey(Schema.String),
|
|
51
|
+
parameters: Schema.optionalKey(Parameters),
|
|
52
|
+
timeout: Schema.optionalKey(PositiveInt),
|
|
53
|
+
orderingSpecifier: Schema.optionalKey(OrderingSpecifier),
|
|
54
|
+
serializationOptions: Schema.optionalKey(
|
|
55
|
+
Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean]))
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
export type ExportBody = typeof ExportBody.Type
|
|
59
|
+
|
|
60
|
+
export const Row = Schema.Record(Schema.String, Schema.Unknown)
|
|
61
|
+
export type Row = typeof Row.Type
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A bare array of rows. Numbers arrive as text, null cells are left out, and
|
|
65
|
+
* unless `includeSystem` or `includeSynthetic` is false each row ends with the
|
|
66
|
+
* system columns `:id`, `:version`, `:created_at` and `:updated_at`.
|
|
67
|
+
*/
|
|
68
|
+
export const QueryResponse = Schema.Array(Row)
|
|
69
|
+
|
|
70
|
+
/** A query: the body with SoQL as text or as a fragment. */
|
|
71
|
+
export interface QueryRequest extends DatasetRef, Omit<QueryBody, "query"> {
|
|
72
|
+
readonly query: string | Soql.Fragment
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A CSV export, narrowed by `query` when given. CSV is the format SODA3
|
|
77
|
+
* exported on 2026-11-01; it refused `json` and `geojson` with a 406 on every
|
|
78
|
+
* dataset tried.
|
|
79
|
+
*/
|
|
80
|
+
export interface ExportRequest extends DatasetRef, Omit<ExportBody, "query"> {
|
|
81
|
+
readonly query?: string | Soql.Fragment
|
|
82
|
+
/**
|
|
83
|
+
* Ask for a byte range. On 2026-11-01 SODA3 ignored it and sent the whole
|
|
84
|
+
* export with a 200; `ntk socrata range-probe` checks again.
|
|
85
|
+
*/
|
|
86
|
+
readonly range?: { readonly start: number; readonly end?: number }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type QueryError =
|
|
90
|
+
| HttpClientError.HttpClientError
|
|
91
|
+
| SocrataError
|
|
92
|
+
| Soql.SoqlError
|
|
93
|
+
| Schema.SchemaError
|
|
94
|
+
|
|
95
|
+
export type ExportError = HttpClientError.HttpClientError | SocrataError | Soql.SoqlError
|
|
96
|
+
|
|
97
|
+
export const viewUrl = (ref: DatasetRef, file: string) =>
|
|
98
|
+
`https://${ref.domain}/api/v3/views/${ref.datasetId}/${file}`
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* SODA3 query, and export as CSV. Socrata refuses both without an app token,
|
|
102
|
+
* so the layer requires one. Retries and timeouts belong to the caller's
|
|
103
|
+
* HttpClient. SODA3 has no default limit: a query without `LIMIT` or `page`
|
|
104
|
+
* returns every matching row.
|
|
105
|
+
*/
|
|
106
|
+
export class Soda3Client extends Context.Service<
|
|
107
|
+
Soda3Client,
|
|
108
|
+
{
|
|
109
|
+
readonly query: (request: QueryRequest) => Effect.Effect<ReadonlyArray<Row>, QueryError>
|
|
110
|
+
readonly export: (request: ExportRequest) => Stream.Stream<Uint8Array, ExportError>
|
|
111
|
+
}
|
|
112
|
+
>()("@nyc-transit-kit/soda3/Soda3Client") {}
|
|
113
|
+
|
|
114
|
+
const soqlText = (query: string | Soql.Fragment) =>
|
|
115
|
+
typeof query === "string" ? Effect.succeed(query) : Soql.compile(query)
|
|
116
|
+
|
|
117
|
+
// Only these fields reach the body, so a request built by spreading a richer
|
|
118
|
+
// object, such as a curated `Dataset`, sends nothing else.
|
|
119
|
+
const queryOptions = [
|
|
120
|
+
"page",
|
|
121
|
+
"parameters",
|
|
122
|
+
"timeout",
|
|
123
|
+
"includeSystem",
|
|
124
|
+
"includeSynthetic",
|
|
125
|
+
"orderingSpecifier"
|
|
126
|
+
] as const
|
|
127
|
+
const exportOptions = [
|
|
128
|
+
"parameters",
|
|
129
|
+
"timeout",
|
|
130
|
+
"orderingSpecifier",
|
|
131
|
+
"serializationOptions"
|
|
132
|
+
] as const
|
|
133
|
+
|
|
134
|
+
const exportBody = Effect.fnUntraced(function* (request: ExportRequest) {
|
|
135
|
+
const body = Struct.pick(request, exportOptions)
|
|
136
|
+
return request.query === undefined ? body : { ...body, query: yield* soqlText(request.query) }
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
export const layer = (options: { readonly appToken: Redacted.Redacted }) =>
|
|
140
|
+
Layer.effect(
|
|
141
|
+
Soda3Client,
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
const client = (yield* HttpClient.HttpClient).pipe(
|
|
144
|
+
HttpClient.mapRequest(
|
|
145
|
+
HttpClientRequest.setHeader("x-app-token", Redacted.value(options.appToken))
|
|
146
|
+
),
|
|
147
|
+
HttpClient.transformResponse(Effect.flatMap(filterStatusOk))
|
|
148
|
+
)
|
|
149
|
+
const post = (url: string, body: object, headers: Readonly<Record<string, string>> = {}) =>
|
|
150
|
+
client.execute(
|
|
151
|
+
HttpClientRequest.post(url, { headers }).pipe(HttpClientRequest.bodyJsonUnsafe(body))
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const query = Effect.fn("Soda3Client.query")(function* (request: QueryRequest) {
|
|
155
|
+
const response = yield* post(
|
|
156
|
+
viewUrl(request, "query.json"),
|
|
157
|
+
{ ...Struct.pick(request, queryOptions), query: yield* soqlText(request.query) },
|
|
158
|
+
{ accept: "application/json" }
|
|
159
|
+
)
|
|
160
|
+
return yield* HttpClientResponse.schemaBodyJson(QueryResponse)(response)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const exportBytes = (request: ExportRequest) =>
|
|
164
|
+
Stream.unwrap(
|
|
165
|
+
Effect.map(exportBody(request), (body) =>
|
|
166
|
+
HttpClientResponse.stream(
|
|
167
|
+
post(
|
|
168
|
+
viewUrl(request, "export.csv"),
|
|
169
|
+
body,
|
|
170
|
+
request.range === undefined
|
|
171
|
+
? {}
|
|
172
|
+
: { range: `bytes=${request.range.start}-${request.range.end ?? ""}` }
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
).pipe(Stream.withSpan("Soda3Client.export"))
|
|
177
|
+
|
|
178
|
+
return Soda3Client.of({ query, export: exportBytes })
|
|
179
|
+
})
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Every row of a query, one page per request. Pages end at the first short page.
|
|
184
|
+
*
|
|
185
|
+
* A page is a slice of the query's order, so order by something that puts new
|
|
186
|
+
* rows last, such as the dataset's timestamp column, when the dataset changes
|
|
187
|
+
* while you page. Without an `ORDER BY`, SODA3 served Traffic Speeds newest
|
|
188
|
+
* first, requests seconds apart disagreed on the newest rows, and page 2
|
|
189
|
+
* repeated page 1. SODA3 refuses paging with `orderingSpecifier: "discard"`,
|
|
190
|
+
* so this takes none.
|
|
191
|
+
*/
|
|
192
|
+
export const streamRows = (
|
|
193
|
+
request: Omit<QueryRequest, "page" | "orderingSpecifier">,
|
|
194
|
+
pageSize = 1000
|
|
195
|
+
) =>
|
|
196
|
+
Stream.paginate(1, (pageNumber: number) =>
|
|
197
|
+
Effect.gen(function* () {
|
|
198
|
+
const soda = yield* Soda3Client
|
|
199
|
+
const rows = yield* soda.query({ ...request, page: { pageNumber, pageSize } })
|
|
200
|
+
const next = rows.length < pageSize ? Option.none<number>() : Option.some(pageNumber + 1)
|
|
201
|
+
return [rows, next] as const
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
// SODA3 sends `count(*)` as text, like every number in a row.
|
|
206
|
+
const CountRows = Schema.Tuple([Schema.Struct({ count: Schema.FiniteFromString })])
|
|
207
|
+
|
|
208
|
+
/** `count(*)` of the rows that match `where`, or of all rows. */
|
|
209
|
+
export const countRows = Effect.fn("Soda3Client.countRows")(function* (
|
|
210
|
+
ref: DatasetRef,
|
|
211
|
+
where?: Soql.Fragment
|
|
212
|
+
) {
|
|
213
|
+
const soda = yield* Soda3Client
|
|
214
|
+
const rows = yield* soda.query({
|
|
215
|
+
...ref,
|
|
216
|
+
query:
|
|
217
|
+
where === undefined
|
|
218
|
+
? Soql.soql`SELECT count(*) AS count`
|
|
219
|
+
: Soql.soql`SELECT count(*) AS count WHERE ${where}`,
|
|
220
|
+
includeSynthetic: false
|
|
221
|
+
})
|
|
222
|
+
const [row] = yield* Schema.decodeUnknownEffect(CountRows)(rows)
|
|
223
|
+
return row.count
|
|
224
|
+
})
|