@nyc-transit-kit/soda3 0.1.3 → 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/README.md +27 -2
- package/package.json +6 -4
- package/src/catalog.ts +8 -3
- package/src/client.ts +42 -10
- package/src/endpoints.ts +16 -37
- package/src/export.ts +9 -4
- package/src/index.ts +17 -2
- package/src/internal/decode.ts +12 -0
- package/src/internal/transport.ts +118 -44
- package/src/metadata.ts +37 -0
- package/src/query.ts +108 -6
- package/src/soql.ts +124 -263
- package/src/transport.ts +145 -0
- package/src/internal/bodies.ts +0 -21
- package/src/internal/http.ts +0 -42
- package/src/internal/responses.ts +0 -56
- package/src/internal/services.ts +0 -41
package/README.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @nyc-transit-kit/soda3
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Generic Effect-native client for Socrata SODA3 query, export, and Discovery
|
|
4
|
+
catalog APIs.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Socrata-backed data access is SODA3-only. This package does not expose legacy
|
|
7
|
+
Socrata endpoint helpers, fallback endpoint builders, or compatibility aliases.
|
|
8
|
+
|
|
9
|
+
## Import Paths
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { soda3Layer } from "@nyc-transit-kit/soda3/client"
|
|
13
|
+
import { queryRows } from "@nyc-transit-kit/soda3/query"
|
|
14
|
+
import * as Effect from "effect/Effect"
|
|
15
|
+
|
|
16
|
+
const program = queryRows({
|
|
17
|
+
domain: "data.cityofnewyork.us",
|
|
18
|
+
datasetId: "ycrg-ses3",
|
|
19
|
+
query: "SELECT * LIMIT 5"
|
|
20
|
+
}).pipe(Effect.provide(soda3Layer({ retryTimes: 2 })))
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Prefer subpaths such as `@nyc-transit-kit/soda3/query`,
|
|
24
|
+
`@nyc-transit-kit/soda3/export`, `@nyc-transit-kit/soda3/catalog`, and
|
|
25
|
+
`@nyc-transit-kit/soda3/client`.
|
|
26
|
+
|
|
27
|
+
## Compatibility
|
|
28
|
+
|
|
29
|
+
Each `nyc-transit-kit` release pins one exact Effect 4 beta. Match the Effect
|
|
30
|
+
version documented in `docs/getting-started.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyc-transit-kit/soda3",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"sideEffects": false,
|
|
6
7
|
"exports": {
|
|
@@ -10,8 +11,10 @@
|
|
|
10
11
|
"./endpoints": "./src/endpoints.ts",
|
|
11
12
|
"./errors": "./src/errors.ts",
|
|
12
13
|
"./export": "./src/export.ts",
|
|
14
|
+
"./metadata": "./src/metadata.ts",
|
|
13
15
|
"./query": "./src/query.ts",
|
|
14
|
-
"./soql": "./src/soql.ts"
|
|
16
|
+
"./soql": "./src/soql.ts",
|
|
17
|
+
"./transport": "./src/transport.ts"
|
|
15
18
|
},
|
|
16
19
|
"types": "./src/index.ts",
|
|
17
20
|
"files": [
|
|
@@ -24,13 +27,12 @@
|
|
|
24
27
|
"test": "bun test ./test"
|
|
25
28
|
},
|
|
26
29
|
"dependencies": {
|
|
27
|
-
"@nyc-transit-kit/contracts": "0.
|
|
30
|
+
"@nyc-transit-kit/contracts": "0.2.0",
|
|
28
31
|
"effect": "4.0.0-beta.92"
|
|
29
32
|
},
|
|
30
33
|
"publishConfig": {
|
|
31
34
|
"access": "public"
|
|
32
35
|
},
|
|
33
|
-
"license": "MIT",
|
|
34
36
|
"repository": {
|
|
35
37
|
"type": "git",
|
|
36
38
|
"url": "git+https://github.com/mannyc2/nyc-transit-kit.git",
|
package/src/catalog.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type { Soda3CatalogSearchRequestInput } from "@nyc-transit-kit/contracts/soda3"
|
|
2
2
|
import * as Effect from "effect/Effect"
|
|
3
3
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
|
+
import { Soda3ClientConfig } from "./client"
|
|
4
5
|
import { buildCatalogSearchUrl } from "./endpoints"
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
addHeaders,
|
|
8
|
+
decodeCatalogSearchResponse,
|
|
9
|
+
decodeJson,
|
|
10
|
+
executeRequest,
|
|
11
|
+
requestWithPolicy
|
|
12
|
+
} from "./internal/transport"
|
|
8
13
|
|
|
9
14
|
export { buildCatalogSearchUrl } from "./endpoints"
|
|
10
15
|
export type { Soda3CatalogSearchRequestInput }
|
package/src/client.ts
CHANGED
|
@@ -1,12 +1,44 @@
|
|
|
1
|
+
import * as Context from "effect/Context"
|
|
2
|
+
import * as Layer from "effect/Layer"
|
|
3
|
+
import { type FetchImplementation, fetchHttpClientLayer } from "./transport"
|
|
4
|
+
|
|
1
5
|
export { catalogSearch } from "./catalog"
|
|
2
6
|
export { exportResponse } from "./export"
|
|
3
|
-
export {
|
|
4
|
-
export
|
|
5
|
-
export {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export { fetchDatasetMetadata } from "./metadata"
|
|
8
|
+
export { countRows, queryAllRows, queryRows } from "./query"
|
|
9
|
+
export type { FetchImplementation } from "./transport"
|
|
10
|
+
|
|
11
|
+
export interface Soda3ClientConfigShape {
|
|
12
|
+
readonly appToken?: string
|
|
13
|
+
readonly retryTimes?: number
|
|
14
|
+
readonly retryDelayMs?: number
|
|
15
|
+
readonly timeoutMs?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Soda3LayerOptions extends Soda3ClientConfigShape {
|
|
19
|
+
readonly fetch?: FetchImplementation
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Soda3ClientConfig extends Context.Service<Soda3ClientConfig, Soda3ClientConfigShape>()(
|
|
23
|
+
"@nyc-transit-kit/soda3/Soda3ClientConfig"
|
|
24
|
+
) {
|
|
25
|
+
static readonly Default = Layer.succeed(Soda3ClientConfig)({
|
|
26
|
+
retryTimes: 0
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const soda3ConfigLayer = (options: Soda3ClientConfigShape = {}) =>
|
|
31
|
+
Layer.succeed(Soda3ClientConfig)({
|
|
32
|
+
retryTimes: options.retryTimes ?? 0,
|
|
33
|
+
...(options.appToken === undefined ? {} : { appToken: options.appToken }),
|
|
34
|
+
...(options.retryDelayMs === undefined ? {} : { retryDelayMs: options.retryDelayMs }),
|
|
35
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
export const Soda3HttpLive = fetchHttpClientLayer()
|
|
39
|
+
export const soda3FetchLayer = fetchHttpClientLayer
|
|
40
|
+
|
|
41
|
+
export const soda3Layer = (options: Soda3LayerOptions = {}) =>
|
|
42
|
+
Layer.mergeAll(soda3ConfigLayer(options), soda3FetchLayer(options.fetch))
|
|
43
|
+
|
|
44
|
+
export const Soda3Live = soda3Layer()
|
package/src/endpoints.ts
CHANGED
|
@@ -7,48 +7,15 @@ import {
|
|
|
7
7
|
type Soda3QueryRequestInput
|
|
8
8
|
} from "@nyc-transit-kit/contracts/soda3"
|
|
9
9
|
import * as Effect from "effect/Effect"
|
|
10
|
-
import
|
|
11
|
-
import { InvalidInputError } from "./errors"
|
|
10
|
+
import { decodeInput } from "./internal/decode"
|
|
12
11
|
|
|
13
12
|
export const socrataApiVersion = "v3"
|
|
14
13
|
export const defaultSocrataProtocol = "https"
|
|
15
14
|
export const discoveryApiHost = "api.us.socrata.com"
|
|
16
15
|
|
|
17
|
-
const decodeQueryRequest = (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Effect.fail(
|
|
21
|
-
InvalidInputError.make({
|
|
22
|
-
operation: "query",
|
|
23
|
-
message: error.message
|
|
24
|
-
})
|
|
25
|
-
)
|
|
26
|
-
)
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
const decodeExportRequest = (input: unknown) =>
|
|
30
|
-
Schema.decodeUnknownEffect(Soda3ExportRequest)(input).pipe(
|
|
31
|
-
Effect.catchTag("SchemaError", (error) =>
|
|
32
|
-
Effect.fail(
|
|
33
|
-
InvalidInputError.make({
|
|
34
|
-
operation: "export",
|
|
35
|
-
message: error.message
|
|
36
|
-
})
|
|
37
|
-
)
|
|
38
|
-
)
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
const decodeCatalogSearchRequest = (input: unknown) =>
|
|
42
|
-
Schema.decodeUnknownEffect(Soda3CatalogSearchRequest)(input).pipe(
|
|
43
|
-
Effect.catchTag("SchemaError", (error) =>
|
|
44
|
-
Effect.fail(
|
|
45
|
-
InvalidInputError.make({
|
|
46
|
-
operation: "catalog",
|
|
47
|
-
message: error.message
|
|
48
|
-
})
|
|
49
|
-
)
|
|
50
|
-
)
|
|
51
|
-
)
|
|
16
|
+
const decodeQueryRequest = decodeInput("query", Soda3QueryRequest)
|
|
17
|
+
const decodeExportRequest = decodeInput("export", Soda3ExportRequest)
|
|
18
|
+
const decodeCatalogSearchRequest = decodeInput("catalog", Soda3CatalogSearchRequest)
|
|
52
19
|
|
|
53
20
|
const viewUrl = (domain: string, datasetId: string, suffix: string) => {
|
|
54
21
|
const url = new URL(`${defaultSocrataProtocol}://${domain}`)
|
|
@@ -83,6 +50,18 @@ export const buildCatalogSearchUrl = Effect.fn("Soda3.buildCatalogSearchUrl")(fu
|
|
|
83
50
|
if (request.query !== undefined) {
|
|
84
51
|
url.searchParams.set("search", request.query)
|
|
85
52
|
}
|
|
53
|
+
for (const id of request.ids ?? []) {
|
|
54
|
+
url.searchParams.append("ids", id)
|
|
55
|
+
}
|
|
56
|
+
for (const category of request.categories ?? []) {
|
|
57
|
+
url.searchParams.append("categories", category)
|
|
58
|
+
}
|
|
59
|
+
for (const tag of request.tags ?? []) {
|
|
60
|
+
url.searchParams.append("tags", tag)
|
|
61
|
+
}
|
|
62
|
+
if (request.order !== undefined) {
|
|
63
|
+
url.searchParams.set("order", request.order)
|
|
64
|
+
}
|
|
86
65
|
if (request.limit !== undefined) {
|
|
87
66
|
url.searchParams.set("limit", request.limit.toString())
|
|
88
67
|
}
|
package/src/export.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { Soda3ExportRequestInput } from "@nyc-transit-kit/contracts/soda3"
|
|
2
2
|
import * as Effect from "effect/Effect"
|
|
3
3
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
|
+
import { Soda3ClientConfig } from "./client"
|
|
4
5
|
import { buildExportUrl, decodeSoda3ExportRequest } from "./endpoints"
|
|
5
6
|
import { InvalidInputError } from "./errors"
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
import {
|
|
8
|
+
addHeaders,
|
|
9
|
+
executeRequest,
|
|
10
|
+
rangeHeader,
|
|
11
|
+
requestWithPolicy,
|
|
12
|
+
toExportBody,
|
|
13
|
+
toWebResponse
|
|
14
|
+
} from "./internal/transport"
|
|
10
15
|
|
|
11
16
|
export type { Soda3ExportRequestInput }
|
|
12
17
|
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const packageName = "@nyc-transit-kit/soda3"
|
|
2
2
|
export { buildCatalogSearchUrl, catalogSearch } from "./catalog"
|
|
3
|
-
export type { Soda3ClientConfigShape, Soda3LayerOptions } from "./client"
|
|
3
|
+
export type { FetchImplementation, Soda3ClientConfigShape, Soda3LayerOptions } from "./client"
|
|
4
4
|
export {
|
|
5
5
|
Soda3ClientConfig,
|
|
6
6
|
Soda3HttpLive,
|
|
@@ -30,7 +30,10 @@ export {
|
|
|
30
30
|
TimeoutError
|
|
31
31
|
} from "./errors"
|
|
32
32
|
export { exportResponse } from "./export"
|
|
33
|
-
export {
|
|
33
|
+
export type { Soda3DatasetMetadataRequestInput } from "./metadata"
|
|
34
|
+
export { fetchDatasetMetadata } from "./metadata"
|
|
35
|
+
export type { Soda3CountRowsInput, Soda3QueryAllRowsInput } from "./query"
|
|
36
|
+
export { countRows, queryAllRows, queryRows } from "./query"
|
|
34
37
|
export type {
|
|
35
38
|
SocrataTimestampWindow,
|
|
36
39
|
SocrataYearMonth,
|
|
@@ -56,3 +59,15 @@ export {
|
|
|
56
59
|
soqlTimestampRange,
|
|
57
60
|
soqlYearMonthRange
|
|
58
61
|
} from "./soql"
|
|
62
|
+
export type { RequestPolicyConfig } from "./transport"
|
|
63
|
+
export {
|
|
64
|
+
contentLengthOf,
|
|
65
|
+
fetchHttpClientLayer,
|
|
66
|
+
httpFailureInfo,
|
|
67
|
+
httpStatusText,
|
|
68
|
+
isOkStatus,
|
|
69
|
+
redactedUrl,
|
|
70
|
+
retryableStatus,
|
|
71
|
+
toWebResponse,
|
|
72
|
+
withRequestPolicy
|
|
73
|
+
} from "./transport"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { InvalidInputError } from "../errors"
|
|
4
|
+
|
|
5
|
+
export const decodeInput =
|
|
6
|
+
<S extends Schema.Top>(operation: string, schema: S) =>
|
|
7
|
+
(input: unknown) =>
|
|
8
|
+
Schema.decodeUnknownEffect(schema)(input).pipe(
|
|
9
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
10
|
+
Effect.fail(InvalidInputError.make({ operation, message: error.message }))
|
|
11
|
+
)
|
|
12
|
+
)
|
|
@@ -1,15 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Soda3CatalogSearchResponse,
|
|
3
|
+
type Soda3ExportRequest,
|
|
4
|
+
type Soda3QueryRequest,
|
|
5
|
+
Soda3QueryResponse,
|
|
6
|
+
Soda3Row
|
|
7
|
+
} from "@nyc-transit-kit/contracts/soda3"
|
|
1
8
|
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as Schema from "effect/Schema"
|
|
2
10
|
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
11
|
+
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
|
|
3
12
|
import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
13
|
+
import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
|
|
14
|
+
import { Soda3ClientConfig, type Soda3ClientConfigShape } from "../client"
|
|
4
15
|
import type { Soda3ClientError } from "../errors"
|
|
5
16
|
import {
|
|
6
17
|
isRetryableProviderError,
|
|
18
|
+
ProviderContractError,
|
|
7
19
|
ProviderHttpError,
|
|
8
20
|
RetryExhaustedError,
|
|
9
21
|
TimeoutError
|
|
10
22
|
} from "../errors"
|
|
11
|
-
import {
|
|
12
|
-
|
|
23
|
+
import {
|
|
24
|
+
httpFailureInfo,
|
|
25
|
+
httpStatusText,
|
|
26
|
+
isOkStatus,
|
|
27
|
+
retryableStatus,
|
|
28
|
+
withRequestPolicy
|
|
29
|
+
} from "../transport"
|
|
30
|
+
|
|
31
|
+
export { retryableStatus, toWebResponse } from "../transport"
|
|
32
|
+
|
|
33
|
+
const providerRows = Schema.Array(Soda3Row)
|
|
34
|
+
|
|
35
|
+
const decodeUnknown = <S extends Schema.Top>(schema: S, input: unknown) =>
|
|
36
|
+
Schema.decodeUnknownEffect(schema)(input)
|
|
13
37
|
|
|
14
38
|
export const addHeaders = (config: Soda3ClientConfigShape) => ({
|
|
15
39
|
accept: "application/json",
|
|
@@ -18,7 +42,83 @@ export const addHeaders = (config: Soda3ClientConfigShape) => ({
|
|
|
18
42
|
: {})
|
|
19
43
|
})
|
|
20
44
|
|
|
21
|
-
export
|
|
45
|
+
export const toQueryBody = (request: Soda3QueryRequest) => ({
|
|
46
|
+
query: request.query,
|
|
47
|
+
page: request.page,
|
|
48
|
+
parameters: request.parameters,
|
|
49
|
+
timeout: request.timeout,
|
|
50
|
+
includeSystem: request.includeSystem,
|
|
51
|
+
includeSynthetic: request.includeSynthetic,
|
|
52
|
+
orderingSpecifier: request.orderingSpecifier
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const toExportBody = (request: Soda3ExportRequest) => ({
|
|
56
|
+
query: request.query,
|
|
57
|
+
parameters: request.parameters,
|
|
58
|
+
timeout: request.timeout,
|
|
59
|
+
serializationOptions: request.serializationOptions
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export const rangeHeader = (request: Soda3ExportRequest) =>
|
|
63
|
+
request.range === undefined
|
|
64
|
+
? undefined
|
|
65
|
+
: `bytes=${request.range.start}-${request.range.end ?? ""}`
|
|
66
|
+
|
|
67
|
+
export const mapHttpClientError = (operation: string, error: HttpClientError.HttpClientError) => {
|
|
68
|
+
const info = httpFailureInfo(error)
|
|
69
|
+
|
|
70
|
+
return ProviderHttpError.make({
|
|
71
|
+
operation,
|
|
72
|
+
status: info.status,
|
|
73
|
+
statusText: info.statusText,
|
|
74
|
+
retryable: info.status === 0 || retryableStatus(info.status)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const decodeJson = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
|
|
79
|
+
response.json.pipe(
|
|
80
|
+
Effect.catchTag("HttpClientError", () =>
|
|
81
|
+
Effect.fail(
|
|
82
|
+
ProviderContractError.make({
|
|
83
|
+
operation,
|
|
84
|
+
message: "Provider response was not valid JSON"
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
export const decodeQueryResponse = (input: unknown) =>
|
|
91
|
+
decodeUnknown(Soda3QueryResponse, input).pipe(
|
|
92
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
93
|
+
decodeUnknown(providerRows, input).pipe(
|
|
94
|
+
Effect.map((rows) =>
|
|
95
|
+
Soda3QueryResponse.make({
|
|
96
|
+
rows
|
|
97
|
+
})
|
|
98
|
+
),
|
|
99
|
+
Effect.catchTag("SchemaError", () =>
|
|
100
|
+
Effect.fail(
|
|
101
|
+
ProviderContractError.make({
|
|
102
|
+
operation: "query",
|
|
103
|
+
message: error.message
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
export const decodeCatalogSearchResponse = (input: unknown) =>
|
|
112
|
+
decodeUnknown(Soda3CatalogSearchResponse, input).pipe(
|
|
113
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
114
|
+
Effect.fail(
|
|
115
|
+
ProviderContractError.make({
|
|
116
|
+
operation: "catalog",
|
|
117
|
+
message: error.message
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
)
|
|
22
122
|
|
|
23
123
|
export const requestWithPolicy = <A>(
|
|
24
124
|
operation: string,
|
|
@@ -26,55 +126,29 @@ export const requestWithPolicy = <A>(
|
|
|
26
126
|
) =>
|
|
27
127
|
Effect.gen(function* () {
|
|
28
128
|
const config = yield* Soda3ClientConfig
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
})
|
|
44
|
-
)
|
|
45
|
-
)
|
|
46
|
-
)
|
|
47
|
-
: effect
|
|
48
|
-
|
|
49
|
-
const timeoutMs = config.timeoutMs
|
|
50
|
-
const timed =
|
|
51
|
-
timeoutMs === undefined
|
|
52
|
-
? retried
|
|
53
|
-
: retried.pipe(
|
|
54
|
-
Effect.timeoutOrElse({
|
|
55
|
-
duration: timeoutMs,
|
|
56
|
-
orElse: () =>
|
|
57
|
-
Effect.fail(
|
|
58
|
-
TimeoutError.make({
|
|
59
|
-
operation,
|
|
60
|
-
timeoutMs
|
|
61
|
-
})
|
|
62
|
-
)
|
|
63
|
-
})
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
return yield* timed
|
|
129
|
+
return yield* withRequestPolicy(config, effect, {
|
|
130
|
+
isRetryable: isRetryableProviderError,
|
|
131
|
+
onExhausted: (attempts, error) =>
|
|
132
|
+
RetryExhaustedError.make({
|
|
133
|
+
operation,
|
|
134
|
+
attempts,
|
|
135
|
+
message: `Retry attempts exhausted after provider status ${error.status}`
|
|
136
|
+
}),
|
|
137
|
+
onTimeout: (timeoutMs) =>
|
|
138
|
+
TimeoutError.make({
|
|
139
|
+
operation,
|
|
140
|
+
timeoutMs
|
|
141
|
+
})
|
|
142
|
+
})
|
|
67
143
|
})
|
|
68
144
|
|
|
69
|
-
const httpStatusText = (status: number) => `HTTP ${status}`
|
|
70
|
-
|
|
71
145
|
export const executeRequest = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
|
72
146
|
Effect.gen(function* () {
|
|
73
147
|
const response = yield* HttpClient.execute(request).pipe(
|
|
74
148
|
Effect.mapError((error) => mapHttpClientError(operation, error))
|
|
75
149
|
)
|
|
76
150
|
|
|
77
|
-
if (response.status
|
|
151
|
+
if (!isOkStatus(response.status)) {
|
|
78
152
|
return yield* ProviderHttpError.make({
|
|
79
153
|
operation,
|
|
80
154
|
status: response.status,
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Soda3CatalogResultFragment,
|
|
3
|
+
Soda3DatasetMetadataRequest,
|
|
4
|
+
type Soda3DatasetMetadataRequestInput
|
|
5
|
+
} from "@nyc-transit-kit/contracts/soda3"
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import { catalogSearch } from "./catalog"
|
|
8
|
+
import { ProviderContractError } from "./errors"
|
|
9
|
+
import { decodeInput } from "./internal/decode"
|
|
10
|
+
|
|
11
|
+
export type { Soda3DatasetMetadataRequestInput }
|
|
12
|
+
|
|
13
|
+
const decodeDatasetMetadataRequest = decodeInput("metadata", Soda3DatasetMetadataRequest)
|
|
14
|
+
|
|
15
|
+
const matchingResult = (results: ReadonlyArray<Soda3CatalogResultFragment>, datasetId: string) =>
|
|
16
|
+
results.find((result) => String(result.resource.id) === datasetId)
|
|
17
|
+
|
|
18
|
+
export const fetchDatasetMetadata = Effect.fn("Soda3.fetchDatasetMetadata")(function* (
|
|
19
|
+
input: Soda3DatasetMetadataRequestInput
|
|
20
|
+
) {
|
|
21
|
+
const request = yield* decodeDatasetMetadataRequest(input)
|
|
22
|
+
const response = yield* catalogSearch({
|
|
23
|
+
domain: request.domain,
|
|
24
|
+
ids: [request.datasetId],
|
|
25
|
+
limit: 1
|
|
26
|
+
})
|
|
27
|
+
const result = matchingResult(response.results, String(request.datasetId))
|
|
28
|
+
|
|
29
|
+
if (result === undefined) {
|
|
30
|
+
return yield* ProviderContractError.make({
|
|
31
|
+
operation: "metadata",
|
|
32
|
+
message: `Discovery metadata response did not include dataset ${request.datasetId}`
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result
|
|
37
|
+
})
|
package/src/query.ts
CHANGED
|
@@ -1,15 +1,62 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
PositiveInteger,
|
|
3
|
+
type Soda3QueryRequestInput,
|
|
4
|
+
type Soda3Row
|
|
5
|
+
} from "@nyc-transit-kit/contracts/soda3"
|
|
2
6
|
import * as Effect from "effect/Effect"
|
|
3
7
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
8
|
+
import { Soda3ClientConfig } from "./client"
|
|
4
9
|
import { buildQueryUrl, decodeSoda3QueryRequest } from "./endpoints"
|
|
5
|
-
import { InvalidInputError } from "./errors"
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
import { InvalidInputError, ProviderContractError } from "./errors"
|
|
11
|
+
import { decodeInput } from "./internal/decode"
|
|
12
|
+
import {
|
|
13
|
+
addHeaders,
|
|
14
|
+
decodeJson,
|
|
15
|
+
decodeQueryResponse,
|
|
16
|
+
executeRequest,
|
|
17
|
+
requestWithPolicy,
|
|
18
|
+
toQueryBody
|
|
19
|
+
} from "./internal/transport"
|
|
10
20
|
|
|
11
21
|
export type { Soda3QueryRequestInput }
|
|
12
22
|
|
|
23
|
+
export type Soda3QueryAllRowsInput = Omit<Soda3QueryRequestInput, "page"> & {
|
|
24
|
+
readonly pageSize?: number
|
|
25
|
+
readonly maxRows?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Soda3CountRowsInput = {
|
|
29
|
+
readonly domain: string
|
|
30
|
+
readonly datasetId: string
|
|
31
|
+
readonly where?: string
|
|
32
|
+
readonly parameters?: Readonly<Record<string, unknown>>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const decodeHelperPositiveInteger = (name: string, value: number) =>
|
|
36
|
+
decodeInput(
|
|
37
|
+
"query",
|
|
38
|
+
PositiveInteger
|
|
39
|
+
)(value).pipe(
|
|
40
|
+
Effect.mapError((error) =>
|
|
41
|
+
InvalidInputError.make({
|
|
42
|
+
operation: "query",
|
|
43
|
+
message: `Invalid ${name}: ${error.message}`
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const decodeOptionalHelperPositiveInteger = (name: string, value: number | undefined) =>
|
|
49
|
+
value === undefined ? Effect.void : decodeHelperPositiveInteger(name, value)
|
|
50
|
+
|
|
51
|
+
const decodeCountValue = (value: unknown) => {
|
|
52
|
+
const parsed =
|
|
53
|
+
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN
|
|
54
|
+
|
|
55
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const countColumnName = "count"
|
|
59
|
+
|
|
13
60
|
export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3QueryRequestInput) {
|
|
14
61
|
const config = yield* Soda3ClientConfig
|
|
15
62
|
const queryRequest = yield* decodeSoda3QueryRequest(input)
|
|
@@ -34,3 +81,58 @@ export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3Que
|
|
|
34
81
|
)
|
|
35
82
|
)
|
|
36
83
|
})
|
|
84
|
+
|
|
85
|
+
export const queryAllRows = Effect.fn("Soda3.queryAllRows")(function* (
|
|
86
|
+
input: Soda3QueryAllRowsInput
|
|
87
|
+
) {
|
|
88
|
+
const { pageSize: rawPageSize = 1000, maxRows: rawMaxRows, ...queryInput } = input
|
|
89
|
+
const pageSize = yield* decodeHelperPositiveInteger("pageSize", rawPageSize)
|
|
90
|
+
const maxRows = yield* decodeOptionalHelperPositiveInteger("maxRows", rawMaxRows)
|
|
91
|
+
const rows: Array<Soda3Row> = []
|
|
92
|
+
let pageNumber = 1
|
|
93
|
+
|
|
94
|
+
while (true) {
|
|
95
|
+
const response = yield* queryRows({
|
|
96
|
+
...queryInput,
|
|
97
|
+
page: {
|
|
98
|
+
pageNumber,
|
|
99
|
+
pageSize
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
rows.push(...response.rows)
|
|
103
|
+
|
|
104
|
+
if (maxRows !== undefined && rows.length > maxRows) {
|
|
105
|
+
return yield* InvalidInputError.make({
|
|
106
|
+
operation: "query",
|
|
107
|
+
message: `SODA3 query exceeded maxRows=${maxRows}; narrow the query or raise maxRows`
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (response.rows.length < pageSize) {
|
|
112
|
+
return rows
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
pageNumber += 1
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
export const countRows = Effect.fn("Soda3.countRows")(function* (input: Soda3CountRowsInput) {
|
|
120
|
+
const query = `SELECT count(*)${input.where === undefined ? "" : ` WHERE ${input.where}`}`
|
|
121
|
+
const response = yield* queryRows({
|
|
122
|
+
domain: input.domain,
|
|
123
|
+
datasetId: input.datasetId,
|
|
124
|
+
query,
|
|
125
|
+
...(input.parameters === undefined ? {} : { parameters: input.parameters }),
|
|
126
|
+
includeSynthetic: false
|
|
127
|
+
})
|
|
128
|
+
const count = decodeCountValue(response.rows[0]?.[countColumnName])
|
|
129
|
+
|
|
130
|
+
if (count === undefined) {
|
|
131
|
+
return yield* ProviderContractError.make({
|
|
132
|
+
operation: "query",
|
|
133
|
+
message: "SODA3 count(*) response did not include a numeric count"
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return count
|
|
138
|
+
})
|