@nyc-transit-kit/soda3 0.2.0 → 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 -18
- 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 -73
- package/src/catalog.ts +0 -33
- package/src/client.ts +0 -44
- package/src/endpoints.ts +0 -77
- package/src/errors.ts +0 -58
- package/src/export.ts +0 -46
- package/src/internal/decode.ts +0 -12
- package/src/internal/transport.ts +0 -161
- package/src/metadata.ts +0 -37
- package/src/query.ts +0 -138
- package/src/soql.ts +0 -214
- package/src/transport.ts +0 -145
package/src/Soql.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SoQL as `effect/sql` fragments.
|
|
3
|
+
*
|
|
4
|
+
* Text written in a `soql` template is trusted SoQL. Every interpolated value
|
|
5
|
+
* becomes a quoted literal and every `identifier` a backticked column, so input
|
|
6
|
+
* can never change the shape of a query. SODA3 has no bind parameters (`:name`
|
|
7
|
+
* names a system column), so `compile` writes the values into the text.
|
|
8
|
+
*/
|
|
9
|
+
import * as Effect from "effect/Effect"
|
|
10
|
+
import * as Schema from "effect/Schema"
|
|
11
|
+
import * as Statement from "effect/sql/Statement"
|
|
12
|
+
import type { IsoDate } from "./Socrata"
|
|
13
|
+
|
|
14
|
+
export type Fragment = Statement.Fragment
|
|
15
|
+
|
|
16
|
+
/** A value SoQL can write as a literal. Timestamps are strings such as `2026-10-01T00:00:00`. */
|
|
17
|
+
export type Value = string | number | boolean
|
|
18
|
+
|
|
19
|
+
/** A fragment that cannot be written as SoQL, or clauses that make no sense. */
|
|
20
|
+
export class SoqlError extends Schema.TaggedError<SoqlError>()("SoqlError", {
|
|
21
|
+
message: Schema.String
|
|
22
|
+
}) {}
|
|
23
|
+
|
|
24
|
+
/** A column or alias, written as `` `name` `` so that reserved words such as `from` work. */
|
|
25
|
+
export const identifier = (name: string): Statement.Identifier => Statement.identifier(name)
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `` soql`speed > ${limit}` ``. The text is SoQL; each interpolation is a value,
|
|
29
|
+
* an `identifier`, or another fragment spliced in place.
|
|
30
|
+
*/
|
|
31
|
+
export const soql = (
|
|
32
|
+
strings: TemplateStringsArray,
|
|
33
|
+
...args: ReadonlyArray<Value | Fragment | Statement.Segment>
|
|
34
|
+
): Fragment => {
|
|
35
|
+
const segments: Array<Statement.Segment> = []
|
|
36
|
+
strings.forEach((text, index) => {
|
|
37
|
+
if (text !== "") {
|
|
38
|
+
segments.push(Statement.literal(text))
|
|
39
|
+
}
|
|
40
|
+
if (index < args.length) {
|
|
41
|
+
const arg = args[index]
|
|
42
|
+
if (Statement.isFragment(arg)) {
|
|
43
|
+
segments.push(...arg.segments)
|
|
44
|
+
} else if (typeof arg === "object") {
|
|
45
|
+
segments.push(arg)
|
|
46
|
+
} else if (arg !== undefined) {
|
|
47
|
+
segments.push(Statement.parameter(arg))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
return Statement.fragment(segments)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const quote = (text: string) => `'${text.replaceAll("'", "''")}'`
|
|
55
|
+
|
|
56
|
+
const write = (value: unknown) => (typeof value === "string" ? quote(value) : String(value))
|
|
57
|
+
|
|
58
|
+
const compiler = Statement.makeCompiler({
|
|
59
|
+
// `Statement` knows no SoQL dialect. The name only matters to dialect switches, which SoQL never uses.
|
|
60
|
+
dialect: "pg",
|
|
61
|
+
placeholder: (_index, value) => write(value),
|
|
62
|
+
onIdentifier: (name) => `\`${name}\``,
|
|
63
|
+
onRecordUpdate: () => ["", []],
|
|
64
|
+
onCustom: () => ["", []]
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const invalidValue = (value: unknown) =>
|
|
68
|
+
typeof value === "string" ||
|
|
69
|
+
typeof value === "boolean" ||
|
|
70
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
71
|
+
? undefined
|
|
72
|
+
: `SoQL has no literal for ${String(value)}`
|
|
73
|
+
|
|
74
|
+
const invalidSegment = (segment: Statement.Segment): string | undefined => {
|
|
75
|
+
switch (segment._tag) {
|
|
76
|
+
case "Literal":
|
|
77
|
+
return undefined
|
|
78
|
+
case "Identifier":
|
|
79
|
+
// SoQL has no escape for a backtick inside a quoted name.
|
|
80
|
+
return segment.value === "" || segment.value.includes("`")
|
|
81
|
+
? `SoQL cannot name the column ${JSON.stringify(segment.value)}`
|
|
82
|
+
: undefined
|
|
83
|
+
case "Parameter":
|
|
84
|
+
return invalidValue(segment.value)
|
|
85
|
+
case "ArrayHelper":
|
|
86
|
+
return segment.value.length === 0
|
|
87
|
+
? "SoQL IN needs at least one value"
|
|
88
|
+
: segment.value.map(invalidValue).find((problem) => problem !== undefined)
|
|
89
|
+
default:
|
|
90
|
+
return `SoQL has no ${segment._tag}`
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The SoQL text of a fragment. Fails on a column SoQL cannot name or a value it cannot write. */
|
|
95
|
+
export const compile = (fragment: Fragment): Effect.Effect<string, SoqlError> => {
|
|
96
|
+
const problem = fragment.segments.map(invalidSegment).find((message) => message !== undefined)
|
|
97
|
+
return problem === undefined
|
|
98
|
+
? Effect.succeed(compiler.compile(fragment, false)[0])
|
|
99
|
+
: Effect.fail(new SoqlError({ message: problem }))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const eq = (column: string, value: Value): Fragment => soql`${identifier(column)} = ${value}`
|
|
103
|
+
|
|
104
|
+
export const isIn = (column: string, values: readonly [Value, ...Array<Value>]): Fragment =>
|
|
105
|
+
soql`${identifier(column)} IN ${Statement.arrayHelper(values)}`
|
|
106
|
+
|
|
107
|
+
export const isNotNull = (column: string): Fragment => soql`${identifier(column)} IS NOT NULL`
|
|
108
|
+
|
|
109
|
+
const joinParenthesized =
|
|
110
|
+
(separator: string) =>
|
|
111
|
+
(clauses: readonly [Fragment, ...Array<Fragment>]): Fragment =>
|
|
112
|
+
Statement.join(separator, false)(clauses.map((clause) => soql`(${clause})`))
|
|
113
|
+
|
|
114
|
+
/** `(a) AND (b)`. Each clause keeps its own parentheses, so an `OR` inside one stays inside it. */
|
|
115
|
+
export const and = joinParenthesized(" AND ")
|
|
116
|
+
|
|
117
|
+
/** `(a) OR (b)`. */
|
|
118
|
+
export const or = joinParenthesized(" OR ")
|
|
119
|
+
|
|
120
|
+
export type SortDirection = "ASC" | "DESC"
|
|
121
|
+
|
|
122
|
+
export interface OrderBy {
|
|
123
|
+
readonly column: string
|
|
124
|
+
readonly direction?: SortDirection | undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The clauses of a query over whole rows. */
|
|
128
|
+
export interface Clauses {
|
|
129
|
+
readonly where?: Fragment | undefined
|
|
130
|
+
readonly orderBy?: OrderBy | undefined
|
|
131
|
+
readonly limit?: number | undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `SELECT *` with each clause that is given, in SoQL's order. */
|
|
135
|
+
export const selectAll = ({ where, orderBy, limit }: Clauses): Fragment =>
|
|
136
|
+
Statement.fragment([
|
|
137
|
+
Statement.literal("SELECT *"),
|
|
138
|
+
...(where === undefined ? [] : [Statement.literal(" WHERE "), ...where.segments]),
|
|
139
|
+
...(orderBy === undefined
|
|
140
|
+
? []
|
|
141
|
+
: [
|
|
142
|
+
Statement.literal(" ORDER BY "),
|
|
143
|
+
identifier(orderBy.column),
|
|
144
|
+
Statement.literal(` ${orderBy.direction ?? "ASC"}`)
|
|
145
|
+
]),
|
|
146
|
+
...(limit === undefined ? [] : [Statement.literal(" LIMIT "), Statement.parameter(limit)])
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
/** A half-open window of Socrata floating timestamps, `start <= t < end`. */
|
|
150
|
+
export interface TimestampWindow {
|
|
151
|
+
readonly start: string
|
|
152
|
+
readonly end: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface YearMonth {
|
|
156
|
+
readonly year: number
|
|
157
|
+
readonly month: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const monthStart = (year: number, month: number) =>
|
|
161
|
+
`${year.toString().padStart(4, "0")}-${month.toString().padStart(2, "0")}-01T00:00:00`
|
|
162
|
+
|
|
163
|
+
/** Midnight at the start of a calendar date, as a floating timestamp. */
|
|
164
|
+
export const timestamp = (date: IsoDate) => `${date}T00:00:00`
|
|
165
|
+
|
|
166
|
+
/** The calendar month as a window. */
|
|
167
|
+
export const monthWindow = ({ year, month }: YearMonth): TimestampWindow => ({
|
|
168
|
+
start: monthStart(year, month),
|
|
169
|
+
end: month === 12 ? monthStart(year + 1, 1) : monthStart(year, month + 1)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/** The days from `start` up to, not including, `end`. Fails when the window is empty. */
|
|
173
|
+
export const dateWindow = (start: IsoDate, end: IsoDate) =>
|
|
174
|
+
start < end
|
|
175
|
+
? Effect.succeed<TimestampWindow>({ start: timestamp(start), end: timestamp(end) })
|
|
176
|
+
: Effect.fail(new SoqlError({ message: `Date window ${start}..${end} is empty` }))
|
|
177
|
+
|
|
178
|
+
/** `column` inside the window. */
|
|
179
|
+
export const timestampRange = (column: string, window: TimestampWindow): Fragment =>
|
|
180
|
+
soql`${identifier(column)} >= ${window.start} AND ${identifier(column)} < ${window.end}`
|
|
181
|
+
|
|
182
|
+
/** `column` inside the calendar month. */
|
|
183
|
+
export const monthRange = (column: string, month: YearMonth): Fragment =>
|
|
184
|
+
timestampRange(column, monthWindow(month))
|
|
185
|
+
|
|
186
|
+
/** Rows whose separate year and month columns fall in `[start, end]`, both months included. */
|
|
187
|
+
export const yearMonthRange = (
|
|
188
|
+
columns: { readonly year: string; readonly month: string },
|
|
189
|
+
start: YearMonth,
|
|
190
|
+
end: YearMonth
|
|
191
|
+
) => {
|
|
192
|
+
if (start.year * 12 + start.month > end.year * 12 + end.month) {
|
|
193
|
+
return Effect.fail(new SoqlError({ message: "Year/month range starts after it ends" }))
|
|
194
|
+
}
|
|
195
|
+
const year = identifier(columns.year)
|
|
196
|
+
const month = identifier(columns.month)
|
|
197
|
+
return Effect.succeed(
|
|
198
|
+
soql`(${year} > ${start.year} OR (${year} = ${start.year} AND ${month} >= ${start.month})) AND (${year} < ${end.year} OR (${year} = ${end.year} AND ${month} <= ${end.month}))`
|
|
199
|
+
)
|
|
200
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,73 +1,5 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
Soda3HttpLive,
|
|
7
|
-
Soda3Live,
|
|
8
|
-
soda3ConfigLayer,
|
|
9
|
-
soda3FetchLayer,
|
|
10
|
-
soda3Layer
|
|
11
|
-
} from "./client"
|
|
12
|
-
export {
|
|
13
|
-
buildExportUrl,
|
|
14
|
-
buildQueryUrl,
|
|
15
|
-
decodeSoda3CatalogSearchRequest,
|
|
16
|
-
decodeSoda3ExportRequest,
|
|
17
|
-
decodeSoda3QueryRequest,
|
|
18
|
-
defaultSocrataProtocol,
|
|
19
|
-
discoveryApiHost,
|
|
20
|
-
socrataApiVersion
|
|
21
|
-
} from "./endpoints"
|
|
22
|
-
export type { Soda3ClientError } from "./errors"
|
|
23
|
-
export {
|
|
24
|
-
InvalidInputError,
|
|
25
|
-
isRetryableProviderError,
|
|
26
|
-
isSoda3ClientError,
|
|
27
|
-
ProviderContractError,
|
|
28
|
-
ProviderHttpError,
|
|
29
|
-
RetryExhaustedError,
|
|
30
|
-
TimeoutError
|
|
31
|
-
} from "./errors"
|
|
32
|
-
export { exportResponse } from "./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"
|
|
37
|
-
export type {
|
|
38
|
-
SocrataTimestampWindow,
|
|
39
|
-
SocrataYearMonth,
|
|
40
|
-
SoqlFragment,
|
|
41
|
-
SoqlParameters,
|
|
42
|
-
SoqlSortDirection
|
|
43
|
-
} from "./soql"
|
|
44
|
-
export {
|
|
45
|
-
socrataDateWindow,
|
|
46
|
-
socrataMonthWindow,
|
|
47
|
-
socrataTimestamp,
|
|
48
|
-
soqlAnd,
|
|
49
|
-
soqlEq,
|
|
50
|
-
soqlIdentifier,
|
|
51
|
-
soqlIn,
|
|
52
|
-
soqlIsNotNull,
|
|
53
|
-
soqlLimit,
|
|
54
|
-
soqlMonthWindow,
|
|
55
|
-
soqlOrderBy,
|
|
56
|
-
soqlParameter,
|
|
57
|
-
soqlParameterName,
|
|
58
|
-
soqlSelectAll,
|
|
59
|
-
soqlTimestampRange,
|
|
60
|
-
soqlYearMonthRange
|
|
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"
|
|
1
|
+
export * as Dataset from "./Dataset"
|
|
2
|
+
export * as Discovery from "./Discovery"
|
|
3
|
+
export * as Socrata from "./Socrata"
|
|
4
|
+
export * as Soda3Client from "./Soda3Client"
|
|
5
|
+
export * as Soql from "./Soql"
|
package/src/catalog.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { Soda3CatalogSearchRequestInput } from "@nyc-transit-kit/contracts/soda3"
|
|
2
|
-
import * as Effect from "effect/Effect"
|
|
3
|
-
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
|
-
import { Soda3ClientConfig } from "./client"
|
|
5
|
-
import { buildCatalogSearchUrl } from "./endpoints"
|
|
6
|
-
import {
|
|
7
|
-
addHeaders,
|
|
8
|
-
decodeCatalogSearchResponse,
|
|
9
|
-
decodeJson,
|
|
10
|
-
executeRequest,
|
|
11
|
-
requestWithPolicy
|
|
12
|
-
} from "./internal/transport"
|
|
13
|
-
|
|
14
|
-
export { buildCatalogSearchUrl } from "./endpoints"
|
|
15
|
-
export type { Soda3CatalogSearchRequestInput }
|
|
16
|
-
|
|
17
|
-
export const catalogSearch = Effect.fn("Soda3.catalogSearch")(function* (
|
|
18
|
-
input: Soda3CatalogSearchRequestInput
|
|
19
|
-
) {
|
|
20
|
-
const config = yield* Soda3ClientConfig
|
|
21
|
-
const url = yield* buildCatalogSearchUrl(input)
|
|
22
|
-
const request = HttpClientRequest.get(url, {
|
|
23
|
-
headers: addHeaders(config)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
return yield* requestWithPolicy(
|
|
27
|
-
"catalog",
|
|
28
|
-
executeRequest("catalog", request).pipe(
|
|
29
|
-
Effect.flatMap((response) => decodeJson("catalog", response)),
|
|
30
|
-
Effect.flatMap(decodeCatalogSearchResponse)
|
|
31
|
-
)
|
|
32
|
-
)
|
|
33
|
-
})
|
package/src/client.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import * as Context from "effect/Context"
|
|
2
|
-
import * as Layer from "effect/Layer"
|
|
3
|
-
import { type FetchImplementation, fetchHttpClientLayer } from "./transport"
|
|
4
|
-
|
|
5
|
-
export { catalogSearch } from "./catalog"
|
|
6
|
-
export { exportResponse } from "./export"
|
|
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
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Soda3CatalogSearchRequest,
|
|
3
|
-
type Soda3CatalogSearchRequestInput,
|
|
4
|
-
Soda3ExportRequest,
|
|
5
|
-
type Soda3ExportRequestInput,
|
|
6
|
-
Soda3QueryRequest,
|
|
7
|
-
type Soda3QueryRequestInput
|
|
8
|
-
} from "@nyc-transit-kit/contracts/soda3"
|
|
9
|
-
import * as Effect from "effect/Effect"
|
|
10
|
-
import { decodeInput } from "./internal/decode"
|
|
11
|
-
|
|
12
|
-
export const socrataApiVersion = "v3"
|
|
13
|
-
export const defaultSocrataProtocol = "https"
|
|
14
|
-
export const discoveryApiHost = "api.us.socrata.com"
|
|
15
|
-
|
|
16
|
-
const decodeQueryRequest = decodeInput("query", Soda3QueryRequest)
|
|
17
|
-
const decodeExportRequest = decodeInput("export", Soda3ExportRequest)
|
|
18
|
-
const decodeCatalogSearchRequest = decodeInput("catalog", Soda3CatalogSearchRequest)
|
|
19
|
-
|
|
20
|
-
const viewUrl = (domain: string, datasetId: string, suffix: string) => {
|
|
21
|
-
const url = new URL(`${defaultSocrataProtocol}://${domain}`)
|
|
22
|
-
url.pathname = `/api/${socrataApiVersion}/views/${datasetId}/${suffix}`
|
|
23
|
-
return url
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const buildQueryUrl = Effect.fn("Soda3.buildQueryUrl")(function* (
|
|
27
|
-
input: Soda3QueryRequestInput
|
|
28
|
-
) {
|
|
29
|
-
const request = yield* decodeQueryRequest(input)
|
|
30
|
-
return viewUrl(request.domain, request.datasetId, "query.json")
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
export const buildExportUrl = Effect.fn("Soda3.buildExportUrl")(function* (
|
|
34
|
-
input: Soda3ExportRequestInput
|
|
35
|
-
) {
|
|
36
|
-
const request = yield* decodeExportRequest(input)
|
|
37
|
-
return viewUrl(request.domain, request.datasetId, `export.${request.format}`)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
export const buildCatalogSearchUrl = Effect.fn("Soda3.buildCatalogSearchUrl")(function* (
|
|
41
|
-
input: Soda3CatalogSearchRequestInput
|
|
42
|
-
) {
|
|
43
|
-
const request = yield* decodeCatalogSearchRequest(input)
|
|
44
|
-
const url = new URL(`${defaultSocrataProtocol}://${discoveryApiHost}`)
|
|
45
|
-
url.pathname = "/api/catalog/v1"
|
|
46
|
-
url.searchParams.set("domains", request.domain)
|
|
47
|
-
url.searchParams.set("search_context", request.domain)
|
|
48
|
-
url.searchParams.set("only", "dataset")
|
|
49
|
-
|
|
50
|
-
if (request.query !== undefined) {
|
|
51
|
-
url.searchParams.set("search", request.query)
|
|
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
|
-
}
|
|
65
|
-
if (request.limit !== undefined) {
|
|
66
|
-
url.searchParams.set("limit", request.limit.toString())
|
|
67
|
-
}
|
|
68
|
-
if (request.offset !== undefined) {
|
|
69
|
-
url.searchParams.set("offset", request.offset.toString())
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return url
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
export const decodeSoda3QueryRequest = decodeQueryRequest
|
|
76
|
-
export const decodeSoda3ExportRequest = decodeExportRequest
|
|
77
|
-
export const decodeSoda3CatalogSearchRequest = decodeCatalogSearchRequest
|
package/src/errors.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import * as Schema from "effect/Schema"
|
|
2
|
-
|
|
3
|
-
export class InvalidInputError extends Schema.TaggedErrorClass<InvalidInputError>()(
|
|
4
|
-
"InvalidInputError",
|
|
5
|
-
{
|
|
6
|
-
operation: Schema.String,
|
|
7
|
-
message: Schema.String
|
|
8
|
-
}
|
|
9
|
-
) {}
|
|
10
|
-
|
|
11
|
-
export class ProviderHttpError extends Schema.TaggedErrorClass<ProviderHttpError>()(
|
|
12
|
-
"ProviderHttpError",
|
|
13
|
-
{
|
|
14
|
-
operation: Schema.String,
|
|
15
|
-
status: Schema.Number,
|
|
16
|
-
statusText: Schema.String,
|
|
17
|
-
retryable: Schema.Boolean
|
|
18
|
-
}
|
|
19
|
-
) {}
|
|
20
|
-
|
|
21
|
-
export class ProviderContractError extends Schema.TaggedErrorClass<ProviderContractError>()(
|
|
22
|
-
"ProviderContractError",
|
|
23
|
-
{
|
|
24
|
-
operation: Schema.String,
|
|
25
|
-
message: Schema.String
|
|
26
|
-
}
|
|
27
|
-
) {}
|
|
28
|
-
|
|
29
|
-
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("TimeoutError", {
|
|
30
|
-
operation: Schema.String,
|
|
31
|
-
timeoutMs: Schema.Number
|
|
32
|
-
}) {}
|
|
33
|
-
|
|
34
|
-
export class RetryExhaustedError extends Schema.TaggedErrorClass<RetryExhaustedError>()(
|
|
35
|
-
"RetryExhaustedError",
|
|
36
|
-
{
|
|
37
|
-
operation: Schema.String,
|
|
38
|
-
attempts: Schema.Number,
|
|
39
|
-
message: Schema.String
|
|
40
|
-
}
|
|
41
|
-
) {}
|
|
42
|
-
|
|
43
|
-
export type Soda3ClientError =
|
|
44
|
-
| InvalidInputError
|
|
45
|
-
| ProviderHttpError
|
|
46
|
-
| ProviderContractError
|
|
47
|
-
| TimeoutError
|
|
48
|
-
| RetryExhaustedError
|
|
49
|
-
|
|
50
|
-
export const isSoda3ClientError = (value: unknown): value is Soda3ClientError =>
|
|
51
|
-
value instanceof InvalidInputError ||
|
|
52
|
-
value instanceof ProviderHttpError ||
|
|
53
|
-
value instanceof ProviderContractError ||
|
|
54
|
-
value instanceof TimeoutError ||
|
|
55
|
-
value instanceof RetryExhaustedError
|
|
56
|
-
|
|
57
|
-
export const isRetryableProviderError = (error: Soda3ClientError): error is ProviderHttpError =>
|
|
58
|
-
error._tag === "ProviderHttpError" && error.retryable
|
package/src/export.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import type { Soda3ExportRequestInput } from "@nyc-transit-kit/contracts/soda3"
|
|
2
|
-
import * as Effect from "effect/Effect"
|
|
3
|
-
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
|
-
import { Soda3ClientConfig } from "./client"
|
|
5
|
-
import { buildExportUrl, decodeSoda3ExportRequest } from "./endpoints"
|
|
6
|
-
import { InvalidInputError } from "./errors"
|
|
7
|
-
import {
|
|
8
|
-
addHeaders,
|
|
9
|
-
executeRequest,
|
|
10
|
-
rangeHeader,
|
|
11
|
-
requestWithPolicy,
|
|
12
|
-
toExportBody,
|
|
13
|
-
toWebResponse
|
|
14
|
-
} from "./internal/transport"
|
|
15
|
-
|
|
16
|
-
export type { Soda3ExportRequestInput }
|
|
17
|
-
|
|
18
|
-
export const exportResponse = Effect.fn("Soda3.exportResponse")(function* (
|
|
19
|
-
input: Soda3ExportRequestInput
|
|
20
|
-
) {
|
|
21
|
-
const config = yield* Soda3ClientConfig
|
|
22
|
-
const exportRequest = yield* decodeSoda3ExportRequest(input)
|
|
23
|
-
const url = yield* buildExportUrl(exportRequest)
|
|
24
|
-
const headers = addHeaders(config)
|
|
25
|
-
const range = rangeHeader(exportRequest)
|
|
26
|
-
|
|
27
|
-
const request = yield* HttpClientRequest.post(url, {
|
|
28
|
-
headers: {
|
|
29
|
-
...headers,
|
|
30
|
-
range
|
|
31
|
-
}
|
|
32
|
-
}).pipe(
|
|
33
|
-
HttpClientRequest.bodyJson(toExportBody(exportRequest)),
|
|
34
|
-
Effect.mapError(() =>
|
|
35
|
-
InvalidInputError.make({
|
|
36
|
-
operation: "export",
|
|
37
|
-
message: "SODA3 export body was not JSON serializable"
|
|
38
|
-
})
|
|
39
|
-
)
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
return yield* requestWithPolicy(
|
|
43
|
-
"export",
|
|
44
|
-
executeRequest("export", request).pipe(Effect.flatMap(toWebResponse))
|
|
45
|
-
)
|
|
46
|
-
})
|
package/src/internal/decode.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
)
|