@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.
@@ -0,0 +1,214 @@
1
+ import * as http from 'http'
2
+ import { createServer, closeServer } from './_util'
3
+ import * as xrpcServer from '../src'
4
+ import xrpc, {
5
+ Client,
6
+ XRPCError,
7
+ XRPCInvalidResponseError,
8
+ } from '@atproto/xrpc'
9
+
10
+ const LEXICONS = [
11
+ {
12
+ lexicon: 1,
13
+ id: 'io.example.error',
14
+ defs: {
15
+ main: {
16
+ type: 'query',
17
+ parameters: {
18
+ type: 'params',
19
+ properties: {
20
+ which: { type: 'string', default: 'foo' },
21
+ },
22
+ },
23
+ errors: [{ name: 'Foo' }, { name: 'Bar' }],
24
+ },
25
+ },
26
+ },
27
+ {
28
+ lexicon: 1,
29
+ id: 'io.example.query',
30
+ defs: {
31
+ main: {
32
+ type: 'query',
33
+ },
34
+ },
35
+ },
36
+ {
37
+ lexicon: 1,
38
+ id: 'io.example.procedure',
39
+ defs: {
40
+ main: {
41
+ type: 'procedure',
42
+ },
43
+ },
44
+ },
45
+ {
46
+ lexicon: 1,
47
+ id: 'io.example.invalidResponse',
48
+ defs: {
49
+ main: {
50
+ type: 'query',
51
+ output: {
52
+ encoding: 'application/json',
53
+ schema: {
54
+ type: 'object',
55
+ required: ['expectedValue'],
56
+ properties: {
57
+ expectedValue: { type: 'string' },
58
+ },
59
+ },
60
+ },
61
+ },
62
+ },
63
+ },
64
+ ]
65
+
66
+ const MISMATCHED_LEXICONS = [
67
+ {
68
+ lexicon: 1,
69
+ id: 'io.example.query',
70
+ defs: {
71
+ main: {
72
+ type: 'procedure',
73
+ },
74
+ },
75
+ },
76
+ {
77
+ lexicon: 1,
78
+ id: 'io.example.procedure',
79
+ defs: {
80
+ main: {
81
+ type: 'query',
82
+ },
83
+ },
84
+ },
85
+ {
86
+ lexicon: 1,
87
+ id: 'io.example.doesNotExist',
88
+ defs: {
89
+ main: {
90
+ type: 'query',
91
+ },
92
+ },
93
+ },
94
+ ]
95
+
96
+ describe('Errors', () => {
97
+ let s: http.Server
98
+ const server = xrpcServer.createServer(LEXICONS, { validateResponse: false }) // disable validateResponse to test client validation
99
+ server.method('io.example.error', (ctx: { params: xrpcServer.Params }) => {
100
+ if (ctx.params.which === 'foo') {
101
+ throw new xrpcServer.InvalidRequestError('It was this one!', 'Foo')
102
+ } else if (ctx.params.which === 'bar') {
103
+ return { status: 400, error: 'Bar', message: 'It was that one!' }
104
+ } else {
105
+ return { status: 400 }
106
+ }
107
+ })
108
+ server.method('io.example.query', () => {
109
+ return undefined
110
+ })
111
+ // @ts-ignore We're intentionally giving the wrong response! -prf
112
+ server.method('io.example.invalidResponse', () => {
113
+ return { encoding: 'json', body: { something: 'else' } }
114
+ })
115
+ server.method('io.example.procedure', () => {
116
+ return undefined
117
+ })
118
+ const client = xrpc.service(`http://localhost:8893`)
119
+ xrpc.addLexicons(LEXICONS)
120
+ const badXrpc = new Client()
121
+ const badClient = badXrpc.service(`http://localhost:8893`)
122
+ badXrpc.addLexicons(MISMATCHED_LEXICONS)
123
+ beforeAll(async () => {
124
+ s = await createServer(8893, server)
125
+ })
126
+ afterAll(async () => {
127
+ await closeServer(s)
128
+ })
129
+
130
+ it('serves requests', async () => {
131
+ try {
132
+ await client.call('io.example.error', {
133
+ which: 'foo',
134
+ })
135
+ throw new Error('Didnt throw')
136
+ } catch (e) {
137
+ expect(e instanceof XRPCError).toBeTruthy()
138
+ expect((e as XRPCError).success).toBeFalsy()
139
+ expect((e as XRPCError).error).toBe('Foo')
140
+ expect((e as XRPCError).message).toBe('It was this one!')
141
+ }
142
+ try {
143
+ await client.call('io.example.error', {
144
+ which: 'bar',
145
+ })
146
+ throw new Error('Didnt throw')
147
+ } catch (e) {
148
+ expect(e instanceof XRPCError).toBeTruthy()
149
+ expect((e as XRPCError).success).toBeFalsy()
150
+ expect((e as XRPCError).error).toBe('Bar')
151
+ expect((e as XRPCError).message).toBe('It was that one!')
152
+ }
153
+ try {
154
+ await client.call('io.example.error', {
155
+ which: 'other',
156
+ })
157
+ throw new Error('Didnt throw')
158
+ } catch (e) {
159
+ expect(e instanceof XRPCError).toBeTruthy()
160
+ expect((e as XRPCError).success).toBeFalsy()
161
+ expect((e as XRPCError).error).toBe('InvalidRequest')
162
+ expect((e as XRPCError).message).toBe('Invalid Request')
163
+ }
164
+ try {
165
+ await client.call('io.example.invalidResponse')
166
+ throw new Error('Didnt throw')
167
+ } catch (e: any) {
168
+ expect(e instanceof XRPCError).toBeTruthy()
169
+ expect(e instanceof XRPCInvalidResponseError).toBeTruthy()
170
+ expect(e.success).toBeFalsy()
171
+ expect(e.error).toBe('Invalid Response')
172
+ expect(e.message).toBe(
173
+ 'The server gave an invalid response and may be out of date.',
174
+ )
175
+ const err = e as XRPCInvalidResponseError
176
+ expect(err.validationError.message).toBe(
177
+ 'Output must have the property "expectedValue"',
178
+ )
179
+ expect(err.responseBody).toStrictEqual({ something: 'else' })
180
+ }
181
+ })
182
+
183
+ it('serves error for missing/mismatch schemas', async () => {
184
+ await client.call('io.example.query') // No error
185
+ await client.call('io.example.procedure') // No error
186
+ try {
187
+ await badClient.call('io.example.query')
188
+ throw new Error('Didnt throw')
189
+ } catch (e: any) {
190
+ expect(e instanceof XRPCError).toBeTruthy()
191
+ expect(e.success).toBeFalsy()
192
+ expect(e.error).toBe('InvalidRequest')
193
+ expect(e.message).toBe('Incorrect HTTP method (POST) expected GET')
194
+ }
195
+ try {
196
+ await badClient.call('io.example.procedure')
197
+ throw new Error('Didnt throw')
198
+ } catch (e: any) {
199
+ expect(e instanceof XRPCError).toBeTruthy()
200
+ expect(e.success).toBeFalsy()
201
+ expect(e.error).toBe('InvalidRequest')
202
+ expect(e.message).toBe('Incorrect HTTP method (GET) expected POST')
203
+ }
204
+ try {
205
+ await badClient.call('io.example.doesNotExist')
206
+ throw new Error('Didnt throw')
207
+ } catch (e: any) {
208
+ expect(e instanceof XRPCError).toBeTruthy()
209
+ expect(e.success).toBeFalsy()
210
+ expect(e.error).toBe('MethodNotImplemented')
211
+ expect(e.message).toBe('Method Not Implemented')
212
+ }
213
+ })
214
+ })
@@ -0,0 +1,189 @@
1
+ import * as http from 'http'
2
+ import { createServer, closeServer } from './_util'
3
+ import * as xrpcServer from '../src'
4
+ import xrpc from '@atproto/xrpc'
5
+
6
+ const LEXICONS = [
7
+ {
8
+ lexicon: 1,
9
+ id: 'io.example.paramTest',
10
+ defs: {
11
+ main: {
12
+ type: 'query',
13
+ parameters: {
14
+ type: 'params',
15
+ required: ['str', 'int', 'num', 'bool', 'arr'],
16
+ properties: {
17
+ str: { type: 'string', minLength: 2, maxLength: 10 },
18
+ int: { type: 'integer', minimum: 2, maximum: 10 },
19
+ num: { type: 'number', minimum: 2, maximum: 10 },
20
+ bool: { type: 'boolean' },
21
+ arr: { type: 'array', items: { type: 'integer' }, maxLength: 2 },
22
+ },
23
+ },
24
+ output: {
25
+ encoding: 'application/json',
26
+ },
27
+ },
28
+ },
29
+ },
30
+ ]
31
+
32
+ describe('Parameters', () => {
33
+ let s: http.Server
34
+ const server = xrpcServer.createServer(LEXICONS)
35
+ server.method(
36
+ 'io.example.paramTest',
37
+ (ctx: { params: xrpcServer.Params }) => ({
38
+ encoding: 'json',
39
+ body: ctx.params,
40
+ }),
41
+ )
42
+ const client = xrpc.service(`http://localhost:8889`)
43
+ xrpc.addLexicons(LEXICONS)
44
+ beforeAll(async () => {
45
+ s = await createServer(8889, server)
46
+ })
47
+ afterAll(async () => {
48
+ await closeServer(s)
49
+ })
50
+
51
+ it('validates query params', async () => {
52
+ const res1 = await client.call('io.example.paramTest', {
53
+ str: 'valid',
54
+ int: 5,
55
+ num: 5.5,
56
+ bool: true,
57
+ arr: [1, 2],
58
+ })
59
+ expect(res1.success).toBeTruthy()
60
+ expect(res1.data.str).toBe('valid')
61
+ expect(res1.data.int).toBe(5)
62
+ expect(res1.data.num).toBe(5.5)
63
+ expect(res1.data.bool).toBe(true)
64
+ expect(res1.data.arr).toEqual([1, 2])
65
+
66
+ const res2 = await client.call('io.example.paramTest', {
67
+ str: 10,
68
+ int: '5',
69
+ num: '5.5',
70
+ bool: 'foo',
71
+ arr: '3',
72
+ })
73
+ expect(res2.success).toBeTruthy()
74
+ expect(res2.data.str).toBe('10')
75
+ expect(res2.data.int).toBe(5)
76
+ expect(res2.data.num).toBe(5.5)
77
+ expect(res2.data.bool).toBe(true)
78
+ expect(res2.data.arr).toEqual([3])
79
+
80
+ await expect(
81
+ client.call('io.example.paramTest', {
82
+ str: 'n',
83
+ int: 5,
84
+ num: 5.5,
85
+ bool: true,
86
+ arr: [1],
87
+ }),
88
+ ).rejects.toThrow('str must not be shorter than 2 characters')
89
+ await expect(
90
+ client.call('io.example.paramTest', {
91
+ str: 'loooooooooooooong',
92
+ int: 5,
93
+ num: 5.5,
94
+ bool: true,
95
+ arr: [1],
96
+ }),
97
+ ).rejects.toThrow('str must not be longer than 10 characters')
98
+ await expect(
99
+ client.call('io.example.paramTest', {
100
+ int: 5,
101
+ num: 5.5,
102
+ bool: true,
103
+ arr: [1],
104
+ }),
105
+ ).rejects.toThrow(`Params must have the property "str"`)
106
+
107
+ await expect(
108
+ client.call('io.example.paramTest', {
109
+ str: 'valid',
110
+ int: -1,
111
+ num: 5.5,
112
+ bool: true,
113
+ arr: [1],
114
+ }),
115
+ ).rejects.toThrow('int can not be less than 2')
116
+ await expect(
117
+ client.call('io.example.paramTest', {
118
+ str: 'valid',
119
+ int: 11,
120
+ num: 5.5,
121
+ bool: true,
122
+ arr: [1],
123
+ }),
124
+ ).rejects.toThrow('int can not be greater than 10')
125
+ await expect(
126
+ client.call('io.example.paramTest', {
127
+ str: 'valid',
128
+ num: 5.5,
129
+ bool: true,
130
+ arr: [1],
131
+ }),
132
+ ).rejects.toThrow(`Params must have the property "int"`)
133
+
134
+ await expect(
135
+ client.call('io.example.paramTest', {
136
+ str: 'valid',
137
+ int: 5,
138
+ num: -5.5,
139
+ bool: true,
140
+ arr: [1],
141
+ }),
142
+ ).rejects.toThrow('num can not be less than 2')
143
+ await expect(
144
+ client.call('io.example.paramTest', {
145
+ str: 'valid',
146
+ int: 5,
147
+ num: 50.5,
148
+ bool: true,
149
+ arr: [1],
150
+ }),
151
+ ).rejects.toThrow('num can not be greater than 10')
152
+ await expect(
153
+ client.call('io.example.paramTest', {
154
+ str: 'valid',
155
+ int: 5,
156
+ bool: true,
157
+ arr: [1],
158
+ }),
159
+ ).rejects.toThrow(`Params must have the property "num"`)
160
+
161
+ await expect(
162
+ client.call('io.example.paramTest', {
163
+ str: 'valid',
164
+ int: 5,
165
+ num: 5.5,
166
+ arr: [1],
167
+ }),
168
+ ).rejects.toThrow(`Params must have the property "bool"`)
169
+
170
+ await expect(
171
+ client.call('io.example.paramTest', {
172
+ str: 'valid',
173
+ int: 5,
174
+ num: 5.5,
175
+ bool: true,
176
+ arr: [],
177
+ }),
178
+ ).rejects.toThrow('Error: Params must have the property "arr"')
179
+ await expect(
180
+ client.call('io.example.paramTest', {
181
+ str: 'valid',
182
+ int: 5,
183
+ num: 5.5,
184
+ bool: true,
185
+ arr: [1, 2, 3],
186
+ }),
187
+ ).rejects.toThrow('Error: arr must not have more than 2 elements')
188
+ })
189
+ })
@@ -0,0 +1,165 @@
1
+ import * as http from 'http'
2
+ import { Readable } from 'stream'
3
+ import xrpc from '@atproto/xrpc'
4
+ import { createServer, closeServer } from './_util'
5
+ import * as xrpcServer from '../src'
6
+
7
+ const LEXICONS = [
8
+ {
9
+ lexicon: 1,
10
+ id: 'io.example.ping1',
11
+ defs: {
12
+ main: {
13
+ type: 'procedure',
14
+ parameters: {
15
+ type: 'params',
16
+ properties: {
17
+ message: { type: 'string' },
18
+ },
19
+ },
20
+ output: {
21
+ encoding: 'text/plain',
22
+ },
23
+ },
24
+ },
25
+ },
26
+ {
27
+ lexicon: 1,
28
+ id: 'io.example.ping2',
29
+ defs: {
30
+ main: {
31
+ type: 'procedure',
32
+ input: {
33
+ encoding: 'text/plain',
34
+ },
35
+ output: {
36
+ encoding: 'text/plain',
37
+ },
38
+ },
39
+ },
40
+ },
41
+ {
42
+ lexicon: 1,
43
+ id: 'io.example.ping3',
44
+ defs: {
45
+ main: {
46
+ type: 'procedure',
47
+ input: {
48
+ encoding: 'application/octet-stream',
49
+ },
50
+ output: {
51
+ encoding: 'application/octet-stream',
52
+ },
53
+ },
54
+ },
55
+ },
56
+ {
57
+ lexicon: 1,
58
+ id: 'io.example.ping4',
59
+ defs: {
60
+ main: {
61
+ type: 'procedure',
62
+ input: {
63
+ encoding: 'application/json',
64
+ schema: {
65
+ type: 'object',
66
+ required: ['message'],
67
+ properties: { message: { type: 'string' } },
68
+ },
69
+ },
70
+ output: {
71
+ encoding: 'application/json',
72
+ schema: {
73
+ type: 'object',
74
+ required: ['message'],
75
+ properties: { message: { type: 'string' } },
76
+ },
77
+ },
78
+ },
79
+ },
80
+ },
81
+ ]
82
+
83
+ describe('Procedures', () => {
84
+ let s: http.Server
85
+ const server = xrpcServer.createServer(LEXICONS)
86
+ server.method('io.example.ping1', (ctx: { params: xrpcServer.Params }) => {
87
+ return { encoding: 'text/plain', body: ctx.params.message }
88
+ })
89
+ server.method(
90
+ 'io.example.ping2',
91
+ (ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => {
92
+ return { encoding: 'text/plain', body: ctx.input?.body }
93
+ },
94
+ )
95
+ server.method(
96
+ 'io.example.ping3',
97
+ async (ctx: {
98
+ params: xrpcServer.Params
99
+ input?: xrpcServer.HandlerInput
100
+ }) => {
101
+ if (!(ctx.input?.body instanceof Readable))
102
+ throw new Error('Input not readable')
103
+ const buffers: Buffer[] = []
104
+ for await (const data of ctx.input.body) {
105
+ buffers.push(data)
106
+ }
107
+ return {
108
+ encoding: 'application/octet-stream',
109
+ body: Buffer.concat(buffers),
110
+ }
111
+ },
112
+ )
113
+ server.method(
114
+ 'io.example.ping4',
115
+ (ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => {
116
+ return {
117
+ encoding: 'application/json',
118
+ body: { message: ctx.input?.body?.message },
119
+ }
120
+ },
121
+ )
122
+ const client = xrpc.service(`http://localhost:8891`)
123
+ xrpc.addLexicons(LEXICONS)
124
+ beforeAll(async () => {
125
+ s = await createServer(8891, server)
126
+ })
127
+ afterAll(async () => {
128
+ await closeServer(s)
129
+ })
130
+
131
+ it('serves requests', async () => {
132
+ const res1 = await client.call('io.example.ping1', {
133
+ message: 'hello world',
134
+ })
135
+ expect(res1.success).toBeTruthy()
136
+ expect(res1.headers['content-type']).toBe('text/plain; charset=utf-8')
137
+ expect(res1.data).toBe('hello world')
138
+
139
+ const res2 = await client.call('io.example.ping2', {}, 'hello world', {
140
+ encoding: 'text/plain',
141
+ })
142
+ expect(res2.success).toBeTruthy()
143
+ expect(res2.headers['content-type']).toBe('text/plain; charset=utf-8')
144
+ expect(res2.data).toBe('hello world')
145
+
146
+ const res3 = await client.call(
147
+ 'io.example.ping3',
148
+ {},
149
+ new TextEncoder().encode('hello world'),
150
+ { encoding: 'application/octet-stream' },
151
+ )
152
+ expect(res3.success).toBeTruthy()
153
+ expect(res3.headers['content-type']).toBe('application/octet-stream')
154
+ expect(new TextDecoder().decode(res3.data)).toBe('hello world')
155
+
156
+ const res4 = await client.call(
157
+ 'io.example.ping4',
158
+ {},
159
+ { message: 'hello world' },
160
+ )
161
+ expect(res4.success).toBeTruthy()
162
+ expect(res4.headers['content-type']).toBe('application/json; charset=utf-8')
163
+ expect(res4.data?.message).toBe('hello world')
164
+ })
165
+ })
@@ -0,0 +1,117 @@
1
+ import * as http from 'http'
2
+ import { createServer, closeServer } from './_util'
3
+ import * as xrpcServer from '../src'
4
+ import xrpc from '@atproto/xrpc'
5
+
6
+ const LEXICONS = [
7
+ {
8
+ lexicon: 1,
9
+ id: 'io.example.ping1',
10
+ defs: {
11
+ main: {
12
+ type: 'query',
13
+ parameters: {
14
+ type: 'params',
15
+ properties: {
16
+ message: { type: 'string' },
17
+ },
18
+ },
19
+ output: {
20
+ encoding: 'text/plain',
21
+ },
22
+ },
23
+ },
24
+ },
25
+ {
26
+ lexicon: 1,
27
+ id: 'io.example.ping2',
28
+ defs: {
29
+ main: {
30
+ type: 'query',
31
+ parameters: {
32
+ type: 'params',
33
+ properties: {
34
+ message: { type: 'string' },
35
+ },
36
+ },
37
+ output: {
38
+ encoding: 'application/octet-stream',
39
+ },
40
+ },
41
+ },
42
+ },
43
+ {
44
+ lexicon: 1,
45
+ id: 'io.example.ping3',
46
+ defs: {
47
+ main: {
48
+ type: 'query',
49
+ parameters: {
50
+ type: 'params',
51
+ properties: {
52
+ message: { type: 'string' },
53
+ },
54
+ },
55
+ output: {
56
+ encoding: 'application/json',
57
+ schema: {
58
+ type: 'object',
59
+ required: ['message'],
60
+ properties: { message: { type: 'string' } },
61
+ },
62
+ },
63
+ },
64
+ },
65
+ },
66
+ ]
67
+
68
+ describe('Queries', () => {
69
+ let s: http.Server
70
+ const server = xrpcServer.createServer(LEXICONS)
71
+ server.method('io.example.ping1', (ctx: { params: xrpcServer.Params }) => {
72
+ return { encoding: 'text/plain', body: ctx.params.message }
73
+ })
74
+ server.method('io.example.ping2', (ctx: { params: xrpcServer.Params }) => {
75
+ return {
76
+ encoding: 'application/octet-stream',
77
+ body: new TextEncoder().encode(String(ctx.params.message)),
78
+ }
79
+ })
80
+ server.method('io.example.ping3', (ctx: { params: xrpcServer.Params }) => {
81
+ return {
82
+ encoding: 'application/json',
83
+ body: { message: ctx.params.message },
84
+ }
85
+ })
86
+ const client = xrpc.service(`http://localhost:8890`)
87
+ xrpc.addLexicons(LEXICONS)
88
+ beforeAll(async () => {
89
+ s = await createServer(8890, server)
90
+ })
91
+ afterAll(async () => {
92
+ await closeServer(s)
93
+ })
94
+
95
+ it('serves requests', async () => {
96
+ const res1 = await client.call('io.example.ping1', {
97
+ message: 'hello world',
98
+ })
99
+ expect(res1.success).toBeTruthy()
100
+ expect(res1.headers['content-type']).toBe('text/plain; charset=utf-8')
101
+ expect(res1.data).toBe('hello world')
102
+
103
+ const res2 = await client.call('io.example.ping2', {
104
+ message: 'hello world',
105
+ })
106
+ expect(res2.success).toBeTruthy()
107
+ expect(res2.headers['content-type']).toBe('application/octet-stream')
108
+ expect(new TextDecoder().decode(res2.data)).toBe('hello world')
109
+
110
+ const res3 = await client.call('io.example.ping3', {
111
+ message: 'hello world',
112
+ })
113
+ expect(res3.success).toBeTruthy()
114
+ expect(res3.headers['content-type']).toBe('application/json; charset=utf-8')
115
+ expect(res3.data?.message).toBe('hello world')
116
+ })
117
+ })
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["**/*.spec.ts", "**/*.test.ts"]
4
+ }