@atproto/xrpc-server 0.0.1
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 +42 -0
- package/babel.config.js +1 -0
- package/build.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +34790 -0
- package/dist/index.js.map +7 -0
- package/dist/logger.d.ts +2 -0
- package/dist/server.d.ts +19 -0
- package/dist/types.d.ts +115 -0
- package/dist/util.d.ts +10 -0
- package/jest.config.js +6 -0
- package/package.json +35 -0
- package/src/index.ts +2 -0
- package/src/logger.ts +5 -0
- package/src/server.ts +253 -0
- package/src/types.ts +159 -0
- package/src/util.ts +237 -0
- package/tests/_util.ts +20 -0
- package/tests/auth.test.ts +155 -0
- package/tests/bodies.test.ts +240 -0
- package/tests/errors.test.ts +214 -0
- package/tests/parameters.test.ts +189 -0
- package/tests/procedures.test.ts +165 -0
- package/tests/queries.test.ts +117 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +14 -0
- package/update-pkg.js +14 -0
package/src/util.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Readable, Transform } from 'stream'
|
|
2
|
+
import { createDeflate, createGunzip } from 'zlib'
|
|
3
|
+
import express from 'express'
|
|
4
|
+
import mime from 'mime-types'
|
|
5
|
+
import { Lexicons, LexXrpcProcedure, LexXrpcQuery } from '@atproto/lexicon'
|
|
6
|
+
import { forwardStreamErrors, MaxSizeChecker } from '@atproto/common'
|
|
7
|
+
import {
|
|
8
|
+
UndecodedParams,
|
|
9
|
+
Params,
|
|
10
|
+
HandlerInput,
|
|
11
|
+
HandlerSuccess,
|
|
12
|
+
handlerSuccess,
|
|
13
|
+
InvalidRequestError,
|
|
14
|
+
InternalServerError,
|
|
15
|
+
Options,
|
|
16
|
+
XRPCError,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
export function decodeQueryParams(
|
|
20
|
+
def: LexXrpcProcedure | LexXrpcQuery,
|
|
21
|
+
params: UndecodedParams,
|
|
22
|
+
): Params {
|
|
23
|
+
const decoded: Params = {}
|
|
24
|
+
for (const k in params) {
|
|
25
|
+
const val = params[k]
|
|
26
|
+
const property = def.parameters?.properties?.[k]
|
|
27
|
+
if (property) {
|
|
28
|
+
if (property.type === 'array') {
|
|
29
|
+
const vals: typeof val[] = []
|
|
30
|
+
decoded[k] = val
|
|
31
|
+
? vals
|
|
32
|
+
.concat(val) // Cast to array
|
|
33
|
+
.flatMap((v) => decodeQueryParam(property.items.type, v) ?? [])
|
|
34
|
+
: undefined
|
|
35
|
+
} else {
|
|
36
|
+
decoded[k] = decodeQueryParam(property.type, val)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return decoded
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function decodeQueryParam(
|
|
44
|
+
type: string,
|
|
45
|
+
value: unknown,
|
|
46
|
+
): string | number | boolean | undefined {
|
|
47
|
+
if (!value) {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
if (type === 'string' || type === 'datetime') {
|
|
51
|
+
return String(value)
|
|
52
|
+
}
|
|
53
|
+
if (type === 'number') {
|
|
54
|
+
return Number(String(value))
|
|
55
|
+
} else if (type === 'integer') {
|
|
56
|
+
return Number(String(value)) | 0
|
|
57
|
+
} else if (type === 'boolean') {
|
|
58
|
+
return value === 'true'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function validateInput(
|
|
63
|
+
nsid: string,
|
|
64
|
+
def: LexXrpcProcedure | LexXrpcQuery,
|
|
65
|
+
req: express.Request,
|
|
66
|
+
opts: Options,
|
|
67
|
+
lexicons: Lexicons,
|
|
68
|
+
): HandlerInput | undefined {
|
|
69
|
+
// request expectation
|
|
70
|
+
const reqHasBody = hasBody(req)
|
|
71
|
+
if (reqHasBody && (def.type !== 'procedure' || !def.input)) {
|
|
72
|
+
throw new InvalidRequestError(
|
|
73
|
+
`A request body was provided when none was expected`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
if (def.type === 'query') {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (!reqHasBody && def.input) {
|
|
80
|
+
throw new InvalidRequestError(
|
|
81
|
+
`A request body is expected but none was provided`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// mimetype
|
|
86
|
+
const inputEncoding = normalizeMime(req.headers['content-type'] || '')
|
|
87
|
+
if (
|
|
88
|
+
def.input?.encoding &&
|
|
89
|
+
(!inputEncoding || !isValidEncoding(def.input?.encoding, inputEncoding))
|
|
90
|
+
) {
|
|
91
|
+
throw new InvalidRequestError(`Invalid request encoding: ${inputEncoding}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!inputEncoding) {
|
|
95
|
+
// no input body
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// if input schema, validate
|
|
100
|
+
if (def.input?.schema) {
|
|
101
|
+
try {
|
|
102
|
+
lexicons.assertValidXrpcInput(nsid, req.body)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
throw new InvalidRequestError(e instanceof Error ? e.message : String(e))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// if middleware already got the body, we pass that along as input
|
|
109
|
+
// otherwise, we pass along a decoded readable stream
|
|
110
|
+
let body
|
|
111
|
+
if (req.readableEnded) {
|
|
112
|
+
body = req.body
|
|
113
|
+
} else {
|
|
114
|
+
body = decodeBodyStream(req, opts.payload?.blobLimit)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
encoding: inputEncoding,
|
|
119
|
+
body,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function validateOutput(
|
|
124
|
+
nsid: string,
|
|
125
|
+
def: LexXrpcProcedure | LexXrpcQuery,
|
|
126
|
+
output: HandlerSuccess | undefined,
|
|
127
|
+
lexicons: Lexicons,
|
|
128
|
+
): HandlerSuccess | undefined {
|
|
129
|
+
// initial validation
|
|
130
|
+
if (output) {
|
|
131
|
+
handlerSuccess.parse(output)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// response expectation
|
|
135
|
+
if (output?.body && !def.output) {
|
|
136
|
+
throw new InternalServerError(
|
|
137
|
+
`A response body was provided when none was expected`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
if (!output?.body && def.output) {
|
|
141
|
+
throw new InternalServerError(
|
|
142
|
+
`A response body is expected but none was provided`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// mimetype
|
|
147
|
+
if (
|
|
148
|
+
def.output?.encoding &&
|
|
149
|
+
(!output?.encoding ||
|
|
150
|
+
!isValidEncoding(def.output?.encoding, output?.encoding))
|
|
151
|
+
) {
|
|
152
|
+
throw new InternalServerError(
|
|
153
|
+
`Invalid response encoding: ${output?.encoding}`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// output schema
|
|
158
|
+
if (def.output?.schema) {
|
|
159
|
+
try {
|
|
160
|
+
lexicons.assertValidXrpcOutput(nsid, output?.body)
|
|
161
|
+
} catch (e) {
|
|
162
|
+
throw new InternalServerError(e instanceof Error ? e.message : String(e))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return output
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function normalizeMime(v: string) {
|
|
170
|
+
const fullType = mime.contentType(v)
|
|
171
|
+
if (!v) return false
|
|
172
|
+
const shortType = fullType.split(';')[0]
|
|
173
|
+
if (!shortType) return false
|
|
174
|
+
return shortType
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isValidEncoding(possibleStr: string, value: string) {
|
|
178
|
+
const possible = possibleStr.split(',').map((v) => v.trim())
|
|
179
|
+
const normalized = normalizeMime(value)
|
|
180
|
+
if (!normalized) return false
|
|
181
|
+
if (possible.includes('*/*')) return true
|
|
182
|
+
return possible.includes(normalized)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function hasBody(req: express.Request) {
|
|
186
|
+
const contentLength = req.headers['content-length']
|
|
187
|
+
const transferEncoding = req.headers['transfer-encoding']
|
|
188
|
+
return (contentLength && parseInt(contentLength, 10) > 0) || transferEncoding
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function processBodyAsBytes(req: express.Request): Promise<Uint8Array> {
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
const chunks: Buffer[] = []
|
|
194
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
195
|
+
req.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function decodeBodyStream(
|
|
200
|
+
req: express.Request,
|
|
201
|
+
maxSize: number | undefined,
|
|
202
|
+
): Readable {
|
|
203
|
+
let stream: Readable = req
|
|
204
|
+
const contentEncoding = req.headers['content-encoding']
|
|
205
|
+
const contentLength = req.headers['content-length']
|
|
206
|
+
|
|
207
|
+
if (
|
|
208
|
+
maxSize !== undefined &&
|
|
209
|
+
contentLength &&
|
|
210
|
+
parseInt(contentLength, 10) > maxSize
|
|
211
|
+
) {
|
|
212
|
+
throw new XRPCError(413, 'request entity too large')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let decoder: Transform | undefined
|
|
216
|
+
if (contentEncoding === 'gzip') {
|
|
217
|
+
decoder = createGunzip()
|
|
218
|
+
} else if (contentEncoding === 'deflate') {
|
|
219
|
+
decoder = createDeflate()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (decoder) {
|
|
223
|
+
forwardStreamErrors(stream, decoder)
|
|
224
|
+
stream = stream.pipe(decoder)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (maxSize !== undefined) {
|
|
228
|
+
const maxSizeChecker = new MaxSizeChecker(
|
|
229
|
+
maxSize,
|
|
230
|
+
() => new XRPCError(413, 'request entity too large'),
|
|
231
|
+
)
|
|
232
|
+
forwardStreamErrors(stream, maxSizeChecker)
|
|
233
|
+
stream = stream.pipe(maxSizeChecker)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return stream
|
|
237
|
+
}
|
package/tests/_util.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as http from 'http'
|
|
2
|
+
import express from 'express'
|
|
3
|
+
import * as xrpc from '../src/index'
|
|
4
|
+
|
|
5
|
+
export async function createServer(
|
|
6
|
+
port: number,
|
|
7
|
+
server: xrpc.Server,
|
|
8
|
+
): Promise<http.Server> {
|
|
9
|
+
const app = express()
|
|
10
|
+
app.use(server.router)
|
|
11
|
+
const httpServer = app.listen(port)
|
|
12
|
+
await new Promise((r) => httpServer.on('listening', r))
|
|
13
|
+
return httpServer
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function closeServer(httpServer: http.Server) {
|
|
17
|
+
await new Promise((r) => {
|
|
18
|
+
httpServer.close(() => r(undefined))
|
|
19
|
+
})
|
|
20
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as http from 'http'
|
|
2
|
+
import express from 'express'
|
|
3
|
+
import xrpc, { XRPCError } from '@atproto/xrpc'
|
|
4
|
+
import { createServer, closeServer } from './_util'
|
|
5
|
+
import * as xrpcServer from '../src'
|
|
6
|
+
import { AuthRequiredError } from '../src'
|
|
7
|
+
|
|
8
|
+
const LEXICONS = [
|
|
9
|
+
{
|
|
10
|
+
lexicon: 1,
|
|
11
|
+
id: 'io.example.authTest',
|
|
12
|
+
defs: {
|
|
13
|
+
main: {
|
|
14
|
+
type: 'procedure',
|
|
15
|
+
input: {
|
|
16
|
+
encoding: 'application/json',
|
|
17
|
+
schema: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
present: { type: 'boolean', const: true },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
output: {
|
|
25
|
+
encoding: 'application/json',
|
|
26
|
+
schema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
username: { type: 'string' },
|
|
30
|
+
original: { type: 'string' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
describe('Auth', () => {
|
|
40
|
+
let s: http.Server
|
|
41
|
+
const server = xrpcServer.createServer(LEXICONS)
|
|
42
|
+
server.method('io.example.authTest', {
|
|
43
|
+
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
44
|
+
handler: ({ auth }) => {
|
|
45
|
+
return {
|
|
46
|
+
encoding: 'application/json',
|
|
47
|
+
body: {
|
|
48
|
+
username: auth?.credentials?.username,
|
|
49
|
+
original: auth?.artifacts?.original,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
const client = xrpc.service(`http://localhost:8894`)
|
|
55
|
+
xrpc.addLexicons(LEXICONS)
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
s = await createServer(8894, server)
|
|
58
|
+
})
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
await closeServer(s)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('fails on bad auth before invalid request payload.', async () => {
|
|
64
|
+
try {
|
|
65
|
+
await client.call(
|
|
66
|
+
'io.example.authTest',
|
|
67
|
+
{},
|
|
68
|
+
{ present: false },
|
|
69
|
+
{
|
|
70
|
+
headers: basicAuthHeaders({
|
|
71
|
+
username: 'admin',
|
|
72
|
+
password: 'wrong',
|
|
73
|
+
}),
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
throw new Error('Didnt throw')
|
|
77
|
+
} catch (e: any) {
|
|
78
|
+
expect(e instanceof XRPCError).toBeTruthy()
|
|
79
|
+
expect(e.success).toBeFalsy()
|
|
80
|
+
expect(e.error).toBe('AuthenticationRequired')
|
|
81
|
+
expect(e.message).toBe('Authentication Required')
|
|
82
|
+
expect(e.status).toBe(401)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('fails on invalid request payload after good auth.', async () => {
|
|
87
|
+
try {
|
|
88
|
+
await client.call(
|
|
89
|
+
'io.example.authTest',
|
|
90
|
+
{},
|
|
91
|
+
{ present: false },
|
|
92
|
+
{
|
|
93
|
+
headers: basicAuthHeaders({
|
|
94
|
+
username: 'admin',
|
|
95
|
+
password: 'password',
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
throw new Error('Didnt throw')
|
|
100
|
+
} catch (e: any) {
|
|
101
|
+
expect(e instanceof XRPCError).toBeTruthy()
|
|
102
|
+
expect(e.success).toBeFalsy()
|
|
103
|
+
expect(e.error).toBe('InvalidRequest')
|
|
104
|
+
expect(e.message).toBe('Input/present must be true')
|
|
105
|
+
expect(e.status).toBe(400)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('succeeds on good auth and payload.', async () => {
|
|
110
|
+
const res = await client.call(
|
|
111
|
+
'io.example.authTest',
|
|
112
|
+
{},
|
|
113
|
+
{ present: true },
|
|
114
|
+
{
|
|
115
|
+
headers: basicAuthHeaders({
|
|
116
|
+
username: 'admin',
|
|
117
|
+
password: 'password',
|
|
118
|
+
}),
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
expect(res.success).toBe(true)
|
|
122
|
+
expect(res.data).toEqual({
|
|
123
|
+
username: 'admin',
|
|
124
|
+
original: 'YWRtaW46cGFzc3dvcmQ=',
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
function createBasicAuth(allowed: { username: string; password: string }) {
|
|
130
|
+
return function (ctx: { req: express.Request }) {
|
|
131
|
+
const header = ctx.req.headers.authorization ?? ''
|
|
132
|
+
if (!header.startsWith('Basic ')) {
|
|
133
|
+
throw new AuthRequiredError()
|
|
134
|
+
}
|
|
135
|
+
const original = header.replace('Basic ', '')
|
|
136
|
+
const [username, password] = Buffer.from(original, 'base64')
|
|
137
|
+
.toString()
|
|
138
|
+
.split(':')
|
|
139
|
+
if (username !== allowed.username || password !== allowed.password) {
|
|
140
|
+
throw new AuthRequiredError()
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
credentials: { username },
|
|
144
|
+
artifacts: { original },
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function basicAuthHeaders(creds: { username: string; password: string }) {
|
|
150
|
+
return {
|
|
151
|
+
authorization:
|
|
152
|
+
'Basic ' +
|
|
153
|
+
Buffer.from(`${creds.username}:${creds.password}`).toString('base64'),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import * as http from 'http'
|
|
2
|
+
import { Readable } from 'stream'
|
|
3
|
+
import { gzipSync } from 'zlib'
|
|
4
|
+
import xrpc from '@atproto/xrpc'
|
|
5
|
+
import { bytesToStream, cidForData } from '@atproto/common'
|
|
6
|
+
import { randomBytes } from '@atproto/crypto'
|
|
7
|
+
import { createServer, closeServer } from './_util'
|
|
8
|
+
import * as xrpcServer from '../src'
|
|
9
|
+
import logger from '../src/logger'
|
|
10
|
+
|
|
11
|
+
const LEXICONS = [
|
|
12
|
+
{
|
|
13
|
+
lexicon: 1,
|
|
14
|
+
id: 'io.example.validationTest',
|
|
15
|
+
defs: {
|
|
16
|
+
main: {
|
|
17
|
+
type: 'procedure',
|
|
18
|
+
input: {
|
|
19
|
+
encoding: 'application/json',
|
|
20
|
+
schema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
required: ['foo'],
|
|
23
|
+
properties: {
|
|
24
|
+
foo: { type: 'string' },
|
|
25
|
+
bar: { type: 'number' },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
output: {
|
|
30
|
+
encoding: 'application/json',
|
|
31
|
+
schema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
required: ['foo'],
|
|
34
|
+
properties: {
|
|
35
|
+
foo: { type: 'string' },
|
|
36
|
+
bar: { type: 'number' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
lexicon: 1,
|
|
45
|
+
id: 'io.example.validationTest2',
|
|
46
|
+
defs: {
|
|
47
|
+
main: {
|
|
48
|
+
type: 'query',
|
|
49
|
+
output: {
|
|
50
|
+
encoding: 'application/json',
|
|
51
|
+
schema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
required: ['foo'],
|
|
54
|
+
properties: {
|
|
55
|
+
foo: { type: 'string' },
|
|
56
|
+
bar: { type: 'number' },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
lexicon: 1,
|
|
65
|
+
id: 'io.example.blobTest',
|
|
66
|
+
defs: {
|
|
67
|
+
main: {
|
|
68
|
+
type: 'procedure',
|
|
69
|
+
input: {
|
|
70
|
+
encoding: '*/*',
|
|
71
|
+
},
|
|
72
|
+
output: {
|
|
73
|
+
encoding: 'application/json',
|
|
74
|
+
schema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
required: ['cid'],
|
|
77
|
+
properties: {
|
|
78
|
+
cid: { type: 'string' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const BLOB_LIMIT = 5000
|
|
88
|
+
|
|
89
|
+
describe('Bodies', () => {
|
|
90
|
+
let s: http.Server
|
|
91
|
+
const server = xrpcServer.createServer(LEXICONS, {
|
|
92
|
+
payload: {
|
|
93
|
+
blobLimit: BLOB_LIMIT,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
server.method(
|
|
97
|
+
'io.example.validationTest',
|
|
98
|
+
(ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => ({
|
|
99
|
+
encoding: 'json',
|
|
100
|
+
body: ctx.input?.body,
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
server.method('io.example.validationTest2', () => ({
|
|
104
|
+
encoding: 'json',
|
|
105
|
+
body: { wrong: 'data' },
|
|
106
|
+
}))
|
|
107
|
+
server.method(
|
|
108
|
+
'io.example.blobTest',
|
|
109
|
+
async (ctx: { input?: xrpcServer.HandlerInput }) => {
|
|
110
|
+
if (!(ctx.input?.body instanceof Readable))
|
|
111
|
+
throw new Error('Input not readable')
|
|
112
|
+
const buffers: Buffer[] = []
|
|
113
|
+
for await (const data of ctx.input.body) {
|
|
114
|
+
buffers.push(data)
|
|
115
|
+
}
|
|
116
|
+
const cid = await cidForData(Buffer.concat(buffers))
|
|
117
|
+
return {
|
|
118
|
+
encoding: 'json',
|
|
119
|
+
body: { cid: cid.toString() },
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
const client = xrpc.service(`http://localhost:8892`)
|
|
124
|
+
xrpc.addLexicons(LEXICONS)
|
|
125
|
+
beforeAll(async () => {
|
|
126
|
+
s = await createServer(8892, server)
|
|
127
|
+
})
|
|
128
|
+
afterAll(async () => {
|
|
129
|
+
await closeServer(s)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('validates input and output bodies', async () => {
|
|
133
|
+
const res1 = await client.call(
|
|
134
|
+
'io.example.validationTest',
|
|
135
|
+
{},
|
|
136
|
+
{
|
|
137
|
+
foo: 'hello',
|
|
138
|
+
bar: 123,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
expect(res1.success).toBeTruthy()
|
|
142
|
+
expect(res1.data.foo).toBe('hello')
|
|
143
|
+
expect(res1.data.bar).toBe(123)
|
|
144
|
+
|
|
145
|
+
await expect(client.call('io.example.validationTest', {})).rejects.toThrow(
|
|
146
|
+
`A request body is expected but none was provided`,
|
|
147
|
+
)
|
|
148
|
+
await expect(
|
|
149
|
+
client.call('io.example.validationTest', {}, {}),
|
|
150
|
+
).rejects.toThrow(`Input must have the property "foo"`)
|
|
151
|
+
await expect(
|
|
152
|
+
client.call('io.example.validationTest', {}, { foo: 123 }),
|
|
153
|
+
).rejects.toThrow(`Input/foo must be a string`)
|
|
154
|
+
|
|
155
|
+
// 500 responses don't include details, so we nab details from the logger.
|
|
156
|
+
let error: string | undefined
|
|
157
|
+
const origError = logger.error
|
|
158
|
+
logger.error = (obj, ...args) => {
|
|
159
|
+
error = obj.message
|
|
160
|
+
logger.error = origError
|
|
161
|
+
return logger.error(obj, ...args)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await expect(client.call('io.example.validationTest2')).rejects.toThrow(
|
|
165
|
+
'Internal Server Error',
|
|
166
|
+
)
|
|
167
|
+
expect(error).toEqual(`Output must have the property "foo"`)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('supports blobs and compression', async () => {
|
|
171
|
+
const bytes = randomBytes(1024)
|
|
172
|
+
const expectedCid = await cidForData(bytes)
|
|
173
|
+
|
|
174
|
+
const { data: uncompressed } = await client.call(
|
|
175
|
+
'io.example.blobTest',
|
|
176
|
+
{},
|
|
177
|
+
bytes,
|
|
178
|
+
{
|
|
179
|
+
encoding: 'application/octet-stream',
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
expect(uncompressed.cid).toEqual(expectedCid.toString())
|
|
183
|
+
|
|
184
|
+
const { data: compressed } = await client.call(
|
|
185
|
+
'io.example.blobTest',
|
|
186
|
+
{},
|
|
187
|
+
gzipSync(bytes),
|
|
188
|
+
{
|
|
189
|
+
encoding: 'application/octet-stream',
|
|
190
|
+
headers: {
|
|
191
|
+
'content-encoding': 'gzip',
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
expect(compressed.cid).toEqual(expectedCid.toString())
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('supports max blob size (based on content-length)', async () => {
|
|
199
|
+
const bytes = randomBytes(BLOB_LIMIT + 1)
|
|
200
|
+
|
|
201
|
+
// Exactly the number of allowed bytes
|
|
202
|
+
await client.call('io.example.blobTest', {}, bytes.slice(0, BLOB_LIMIT), {
|
|
203
|
+
encoding: 'application/octet-stream',
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// Over the number of allowed bytes
|
|
207
|
+
const promise = client.call('io.example.blobTest', {}, bytes, {
|
|
208
|
+
encoding: 'application/octet-stream',
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await expect(promise).rejects.toThrow('request entity too large')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('supports max blob size (missing content-length)', async () => {
|
|
215
|
+
// We stream bytes in these tests so that content-length isn't included.
|
|
216
|
+
const bytes = randomBytes(BLOB_LIMIT + 1)
|
|
217
|
+
|
|
218
|
+
// Exactly the number of allowed bytes
|
|
219
|
+
await client.call(
|
|
220
|
+
'io.example.blobTest',
|
|
221
|
+
{},
|
|
222
|
+
bytesToStream(bytes.slice(0, BLOB_LIMIT)),
|
|
223
|
+
{
|
|
224
|
+
encoding: 'application/octet-stream',
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
// Over the number of allowed bytes.
|
|
229
|
+
const promise = client.call(
|
|
230
|
+
'io.example.blobTest',
|
|
231
|
+
{},
|
|
232
|
+
bytesToStream(bytes),
|
|
233
|
+
{
|
|
234
|
+
encoding: 'application/octet-stream',
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
await expect(promise).rejects.toThrow('request entity too large')
|
|
239
|
+
})
|
|
240
|
+
})
|