@dronedeploy/rocos-js-sdk 3.0.0-alpha.19 → 3.0.0-alpha.20

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,14 @@
1
+ import { IRocosCallerMessage, IRocosCallerMessageChunk, IRocosCallerMessageResponse } from '../models';
2
+ import { OperatorFunction } from 'rxjs';
3
+ /** Given a caller message, return the responses contained within it.
4
+ *
5
+ * Chunked responses with acks and results will return the intermediate acks and results as Responses along with the chunk itself
6
+ */
7
+ export declare function getResponses(res: IRocosCallerMessage): (IRocosCallerMessageResponse | IRocosCallerMessageChunk)[];
8
+ /** A pipeable operator that will convert a stream of chunks into a stream of responses.
9
+ *
10
+ * Any non-chunk messages will be passed through unchanged.
11
+ *
12
+ * Chunked responses will be buffered until all chunks are received, then merged into a single response.
13
+ */
14
+ export declare function handleChunkedMessages(): OperatorFunction<IRocosCallerMessageChunk | IRocosCallerMessageResponse, IRocosCallerMessageResponse>;
@@ -0,0 +1,86 @@
1
+ import { map, pipe } from 'rxjs';
2
+ import { filter } from 'rxjs/operators';
3
+ /** Given a caller message, return the responses contained within it.
4
+ *
5
+ * Chunked responses with acks and results will return the intermediate acks and results as Responses along with the chunk itself
6
+ */
7
+ export function getResponses(res) {
8
+ if (res.chunks?.chunks) {
9
+ return getIntermediateResponsesFromChunks(res.chunks.chunks);
10
+ }
11
+ if (res.responses?.responses) {
12
+ return res.responses.responses;
13
+ }
14
+ return [];
15
+ }
16
+ /** Given a list of chunks, convert any intermediate acks and results into responses. */
17
+ function getIntermediateResponsesFromChunks(chunks) {
18
+ const responses = [];
19
+ for (const chunk of chunks) {
20
+ // chunk needs to be pushed first so that the final response can be constructed from the chunks
21
+ // before any result message can close the observable
22
+ responses.push(chunk);
23
+ if (chunk.uid && (chunk.ack || chunk.result)) {
24
+ responses.push({
25
+ uid: chunk.uid,
26
+ ack: chunk.ack,
27
+ result: chunk.result,
28
+ });
29
+ }
30
+ }
31
+ return responses;
32
+ }
33
+ /** A pipeable operator that will convert a stream of chunks into a stream of responses.
34
+ *
35
+ * Any non-chunk messages will be passed through unchanged.
36
+ *
37
+ * Chunked responses will be buffered until all chunks are received, then merged into a single response.
38
+ */
39
+ export function handleChunkedMessages() {
40
+ let chunkBuffer = null;
41
+ return pipe(map((message) => {
42
+ if ('chunkIndex' in message) {
43
+ if (chunkBuffer === null) {
44
+ chunkBuffer = new Array(message.chunkCount);
45
+ }
46
+ chunkBuffer[message.chunkIndex] = message;
47
+ // have to check with Object.keys because the array is buffered and the length is not accurate
48
+ if (Object.keys(chunkBuffer).length !== message.chunkCount) {
49
+ return;
50
+ }
51
+ return buildResponseFromChunks(chunkBuffer);
52
+ }
53
+ return message;
54
+ }), filter(Boolean));
55
+ }
56
+ /** Given a complete set of chunks, build a single response. */
57
+ function buildResponseFromChunks(chunks) {
58
+ const chunkPayloads = chunks.map((x) => x.payload).filter((x) => x !== undefined);
59
+ const payloadLength = chunkPayloads.reduce((acc, x) => acc + x.length, 0);
60
+ const mergedPayload = new Uint8Array(payloadLength);
61
+ for (let i = 0, offset = 0; i < chunkPayloads.length; i++) {
62
+ mergedPayload.set(chunkPayloads[i], offset);
63
+ offset += chunkPayloads[i].length;
64
+ }
65
+ const [header] = chunks.map((x) => x.header).filter((x) => x !== undefined);
66
+ if (header === undefined || Array.isArray(header)) {
67
+ throw new Error('No header found in chunks');
68
+ }
69
+ const [uid] = chunks.map((x) => x.uid).filter((x) => x !== undefined);
70
+ if (uid === undefined || Array.isArray(uid)) {
71
+ throw new Error('No uid found in chunks');
72
+ }
73
+ const createdAt = new Date(Number(header.created) * 1000);
74
+ const createdNs = (BigInt(header.created) * BigInt(1e9)).toString();
75
+ return {
76
+ uid,
77
+ return: {
78
+ header: {
79
+ createdNs,
80
+ createdAt,
81
+ meta: header.meta,
82
+ },
83
+ payload: mergedPayload,
84
+ },
85
+ };
86
+ }
@@ -2,9 +2,14 @@ import { IRocosCallerMessageResponseAck } from './IRocosCallerMessageResponseAck
2
2
  import { IRocosCallerMessageResponseResult } from './IRocosCallerMessageResponseResult';
3
3
  import { IRocosCallerMessageResponseUid } from './IRocosCallerMessageResponseUid';
4
4
  export interface IRocosCallerMessageChunk {
5
+ uid?: IRocosCallerMessageResponseUid;
5
6
  chunkIndex: number;
6
7
  chunkCount: number;
7
- uid?: IRocosCallerMessageResponseUid;
8
+ header?: {
9
+ created: string;
10
+ meta: Record<string, string>;
11
+ };
8
12
  ack?: IRocosCallerMessageResponseAck;
9
13
  result?: IRocosCallerMessageResponseResult;
14
+ payload?: Uint8Array;
10
15
  }
@@ -4,6 +4,7 @@ export * from './IRocosCallerMessageHeartbeat';
4
4
  export * from './IRocosCallerMessageResponse';
5
5
  export * from './IRocosCallerMessageResponseAck';
6
6
  export * from './IRocosCallerMessageResponseResult';
7
+ export * from './IRocosCallerMessageResponseReturn';
7
8
  export * from './IRocosCallerMessageResponses';
8
9
  export * from './IRocosCallerMessageResponseUid';
9
10
  export * from './RocosCallerResultStatus';
@@ -4,6 +4,7 @@ export * from './IRocosCallerMessageHeartbeat';
4
4
  export * from './IRocosCallerMessageResponse';
5
5
  export * from './IRocosCallerMessageResponseAck';
6
6
  export * from './IRocosCallerMessageResponseResult';
7
+ export * from './IRocosCallerMessageResponseReturn';
7
8
  export * from './IRocosCallerMessageResponses';
8
9
  export * from './IRocosCallerMessageResponseUid';
9
10
  export * from './RocosCallerResultStatus';
@@ -14,3 +14,13 @@ export interface ICallerInvokeParams extends ICallerParams {
14
14
  payload: string;
15
15
  query?: Record<string, string[]>;
16
16
  }
17
+ export interface ICallerCallParams {
18
+ projectId: string;
19
+ callsign: string;
20
+ source: string;
21
+ payload?: unknown;
22
+ options?: {
23
+ uid?: string;
24
+ query?: Record<string, string[]>;
25
+ };
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dronedeploy/rocos-js-sdk",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
4
4
  "description": "Javascript SDK for rocos",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,10 +1,42 @@
1
- import { ICallerInvokeParams, ICallerParams, ICallerStream, IRocosCallerMessage, IRocosSDKConfig, IStreamConfig } from '../models';
1
+ import { ICallerCallParams, ICallerInvokeParams, ICallerParams, ICallerStream, IRocosCallerMessage, IRocosCallerMessageResponseAck, IRocosCallerMessageResponseResult, IRocosCallerMessageResponseReturn, IRocosSDKConfig, IStreamConfig } from '../models';
2
2
  import { Observable } from 'rxjs';
3
3
  import { BaseStreamService } from './BaseStreamService';
4
4
  export declare class CallerService extends BaseStreamService<ICallerStream> {
5
5
  constructor(config: IRocosSDKConfig);
6
6
  invokeRequest(params: ICallerInvokeParams): Observable<IRocosCallerMessage>;
7
7
  cancelRequest(params: ICallerParams): Observable<IRocosCallerMessage>;
8
+ /** Call a service and return the response(s).
9
+ *
10
+ * It will complete once a result message is received with a `COMPLETE_SUCCESS` status,
11
+ * or throw an error if the status is not `COMPLETE_SUCCESS`.
12
+ *
13
+ * This is a high level method that wraps the lower level `invokeRequest` method.
14
+ *
15
+ * @see invokeRequest
16
+ * @see call
17
+ */
18
+ callRaw(params: ICallerCallParams): {
19
+ return$: Observable<IRocosCallerMessageResponseReturn>;
20
+ result$: Observable<IRocosCallerMessageResponseResult>;
21
+ ack$: Observable<IRocosCallerMessageResponseAck>;
22
+ };
23
+ /** Call a service and return the response(s) as UTF-8 encoded JSON.
24
+ *
25
+ * It will complete once a result message is received with a `COMPLETE_SUCCESS` status,
26
+ * or throw an error if the status is not `COMPLETE_SUCCESS`.
27
+ *
28
+ * This is a high level method that wraps the lower level `invokeRequest` method.
29
+ *
30
+ * Equivalent to calling `callRaw` and then parsing the return payload as UTF-8 encoded JSON.
31
+ *
32
+ * @see callRaw
33
+ * @see invokeRequest
34
+ */
35
+ call<T = unknown>(params: ICallerCallParams): {
36
+ return$: Observable<T>;
37
+ result$: Observable<IRocosCallerMessageResponseResult>;
38
+ ack$: Observable<IRocosCallerMessageResponseAck>;
39
+ };
8
40
  protected createStream(): Promise<ICallerStream>;
9
41
  protected getStream(config: IStreamConfig): ICallerStream;
10
42
  }
@@ -1,8 +1,11 @@
1
- import { Subject } from 'rxjs';
1
+ import { ResultStatus, RocosResponseLevel, } from '../models';
2
+ import { Subject, map, mergeMap, take, takeUntil } from 'rxjs';
3
+ import { filter, finalize } from 'rxjs/operators';
4
+ import { getResponses, handleChunkedMessages } from '../helpers/callerMessageHelpers';
2
5
  import { BaseStreamService } from './BaseStreamService';
3
6
  import { CallerStream } from '../api/streams/caller/CallerStream';
4
7
  import { IDENTIFIER_NAME_CALLER } from '../constants/identifier';
5
- import { finalize } from 'rxjs/operators';
8
+ import { v4 } from 'uuid';
6
9
  export class CallerService extends BaseStreamService {
7
10
  constructor(config) {
8
11
  super('CallerService', config);
@@ -37,6 +40,67 @@ export class CallerService extends BaseStreamService {
37
40
  subscription?.unsubscribe();
38
41
  }));
39
42
  }
43
+ /** Call a service and return the response(s).
44
+ *
45
+ * It will complete once a result message is received with a `COMPLETE_SUCCESS` status,
46
+ * or throw an error if the status is not `COMPLETE_SUCCESS`.
47
+ *
48
+ * This is a high level method that wraps the lower level `invokeRequest` method.
49
+ *
50
+ * @see invokeRequest
51
+ * @see call
52
+ */
53
+ callRaw(params) {
54
+ const [component, ...topicItems] = params.source.replace(/^\/+/, '').split('/');
55
+ const topic = topicItems.join('/');
56
+ const payloadString = JSON.stringify(params.payload ?? {});
57
+ const source$ = this.invokeRequest({
58
+ uid: params.options?.uid ?? v4(),
59
+ projectId: params.projectId,
60
+ callsign: params.callsign,
61
+ subSystem: '',
62
+ component,
63
+ topic,
64
+ responseLevelNumber: RocosResponseLevel.ALL,
65
+ payload: payloadString,
66
+ query: params.options?.query,
67
+ }).pipe(mergeMap(getResponses), handleChunkedMessages());
68
+ const result$ = source$.pipe(filter((x) => x.result !== undefined), map((x) => x.result), take(1));
69
+ const resultNotifier$ = result$.pipe(map((x) => {
70
+ if (x.status !== ResultStatus.COMPLETE_SUCCESS)
71
+ throw x;
72
+ return x;
73
+ }));
74
+ const return$ = source$.pipe(filter((x) => x.return !== undefined), map((x) => x.return), takeUntil(resultNotifier$));
75
+ const ack$ = source$.pipe(filter((x) => x.ack !== undefined), map((x) => x.ack), takeUntil(resultNotifier$));
76
+ return {
77
+ return$,
78
+ result$,
79
+ ack$,
80
+ };
81
+ }
82
+ /** Call a service and return the response(s) as UTF-8 encoded JSON.
83
+ *
84
+ * It will complete once a result message is received with a `COMPLETE_SUCCESS` status,
85
+ * or throw an error if the status is not `COMPLETE_SUCCESS`.
86
+ *
87
+ * This is a high level method that wraps the lower level `invokeRequest` method.
88
+ *
89
+ * Equivalent to calling `callRaw` and then parsing the return payload as UTF-8 encoded JSON.
90
+ *
91
+ * @see callRaw
92
+ * @see invokeRequest
93
+ */
94
+ call(params) {
95
+ const stream = this.callRaw(params);
96
+ return {
97
+ ...stream,
98
+ return$: stream.return$.pipe(map((x) => {
99
+ const decoded = new TextDecoder().decode(x.payload);
100
+ return JSON.parse(decoded);
101
+ })),
102
+ };
103
+ }
40
104
  async createStream() {
41
105
  return (await this.createStreamFromConfig(IDENTIFIER_NAME_CALLER, {
42
106
  url: this.config.url,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,227 @@
1
+ import { AckStatus, ResultStatus, Stage, UIDVersion, } from '../models';
2
+ import { concatMap, delay, from, of } from 'rxjs';
3
+ import { CallerService } from './CallerService';
4
+ import { TestScheduler } from 'rxjs/testing';
5
+ const makeCallerMessage = (responses) => ({
6
+ responses: {
7
+ responses: responses ?? [],
8
+ },
9
+ });
10
+ const makeCallerChunkedMessage = (responses) => {
11
+ return responses.map((response, index) => {
12
+ if ('return' in response) {
13
+ throw new Error('Cannot create chunked message with return');
14
+ }
15
+ const chunk = response;
16
+ chunk.chunkIndex = index;
17
+ chunk.chunkCount = responses.length;
18
+ chunk.header = {
19
+ created: '0',
20
+ meta: {},
21
+ };
22
+ return {
23
+ chunks: {
24
+ chunks: [chunk],
25
+ },
26
+ };
27
+ });
28
+ };
29
+ const uid = {
30
+ hash: 'hash',
31
+ version: UIDVersion.HEADER_HASH_RAND,
32
+ };
33
+ const ack = (id) => ({
34
+ uid,
35
+ ack: {
36
+ status: AckStatus.PROGRESS,
37
+ message: id,
38
+ stage: Stage.AGENT,
39
+ },
40
+ });
41
+ const ret = (id) => {
42
+ const encoder = new TextEncoder();
43
+ const payload = encoder.encode(JSON.stringify({ id }));
44
+ return {
45
+ uid,
46
+ return: {
47
+ payload,
48
+ },
49
+ };
50
+ };
51
+ const chunkRet = (payload) => {
52
+ const array = new TextEncoder().encode(payload);
53
+ return {
54
+ uid,
55
+ payload: array,
56
+ chunkIndex: 0,
57
+ chunkCount: 1,
58
+ header: {
59
+ created: '0',
60
+ meta: {},
61
+ },
62
+ };
63
+ };
64
+ const res = (ok) => ({
65
+ uid,
66
+ result: {
67
+ status: ok ? ResultStatus.COMPLETE_SUCCESS : ResultStatus.FATAL,
68
+ message: ok ? 'everything is fine' : 'nothing is fine',
69
+ },
70
+ });
71
+ let testScheduler;
72
+ let service;
73
+ describe('CallerService', () => {
74
+ beforeEach(() => {
75
+ service = new CallerService({
76
+ url: 'http://localhost:8080',
77
+ token: 'token',
78
+ });
79
+ testScheduler = new TestScheduler((actual, expected) => {
80
+ expect(actual).toEqual(expected);
81
+ });
82
+ });
83
+ describe('callRaw', () => {
84
+ it('should handle a successful call', () => {
85
+ // Arrange
86
+ jest.spyOn(service, 'invokeRequest').mockImplementation(() => {
87
+ return from([
88
+ makeCallerMessage([ack('a')]),
89
+ makeCallerMessage([ack('b')]),
90
+ makeCallerMessage([ack('c'), ret(1)]),
91
+ makeCallerMessage([ret(2)]),
92
+ makeCallerMessage([ret(3), ret(4)]),
93
+ makeCallerMessage([ack('d')]),
94
+ makeCallerMessage([ack('e')]),
95
+ makeCallerMessage([ack('f')]),
96
+ makeCallerMessage([ack('g'), res(true)]),
97
+ makeCallerMessage([ack('h')]),
98
+ ]).pipe(concatMap((item) => of(item).pipe(delay(1))));
99
+ });
100
+ testScheduler.run(({ expectObservable }) => {
101
+ // Act
102
+ const call = service.call({
103
+ callsign: 'callsign',
104
+ projectId: 'project',
105
+ source: '/test',
106
+ });
107
+ // Assert
108
+ const expectedAckMarbles = '-abc--def|';
109
+ const expectedRetMarbles = '---ab(cd)|';
110
+ const expectedResMarbles = '---------(a|)';
111
+ const expectedAckValues = {
112
+ a: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'a' },
113
+ b: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'b' },
114
+ c: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'c' },
115
+ d: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'd' },
116
+ e: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'e' },
117
+ f: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'f' },
118
+ };
119
+ const expectedReturnValues = {
120
+ a: { id: 1 },
121
+ b: { id: 2 },
122
+ c: { id: 3 },
123
+ d: { id: 4 },
124
+ };
125
+ const expectedResultValues = {
126
+ a: { status: ResultStatus.COMPLETE_SUCCESS, message: 'everything is fine' },
127
+ };
128
+ expectObservable(call.ack$).toBe(expectedAckMarbles, expectedAckValues);
129
+ expectObservable(call.return$).toBe(expectedRetMarbles, expectedReturnValues);
130
+ expectObservable(call.result$).toBe(expectedResMarbles, expectedResultValues);
131
+ });
132
+ });
133
+ it('should handle a failed call', () => {
134
+ // Arrange
135
+ jest.spyOn(service, 'invokeRequest').mockImplementation(() => {
136
+ return from([
137
+ makeCallerMessage([ack('a')]),
138
+ makeCallerMessage([ack('b')]),
139
+ makeCallerMessage([ack('c'), ret(1)]),
140
+ makeCallerMessage([ret(2)]),
141
+ makeCallerMessage([ret(3), ret(4)]),
142
+ makeCallerMessage([ack('d')]),
143
+ makeCallerMessage([ack('e')]),
144
+ makeCallerMessage([ack('f')]),
145
+ makeCallerMessage([ack('g'), res(false)]),
146
+ makeCallerMessage([ack('h')]),
147
+ ]).pipe(concatMap((item) => of(item).pipe(delay(1))));
148
+ });
149
+ testScheduler.run(({ expectObservable }) => {
150
+ // Act
151
+ const call = service.call({
152
+ callsign: 'callsign',
153
+ projectId: 'project',
154
+ source: '/test',
155
+ });
156
+ // Assert
157
+ const expectedAckMarbles = '-abc--def#';
158
+ const expectedRetMarbles = '---ab(cd)#';
159
+ const expectedResMarbles = '---------(a|)';
160
+ const expectedAckValues = {
161
+ a: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'a' },
162
+ b: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'b' },
163
+ c: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'c' },
164
+ d: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'd' },
165
+ e: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'e' },
166
+ f: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'f' },
167
+ };
168
+ const expectedReturnValues = {
169
+ a: { id: 1 },
170
+ b: { id: 2 },
171
+ c: { id: 3 },
172
+ d: { id: 4 },
173
+ };
174
+ const expectedResultValues = {
175
+ a: { status: ResultStatus.FATAL, message: 'nothing is fine' },
176
+ };
177
+ expectObservable(call.ack$).toBe(expectedAckMarbles, expectedAckValues, res(false).result);
178
+ expectObservable(call.return$).toBe(expectedRetMarbles, expectedReturnValues, res(false).result);
179
+ expectObservable(call.result$).toBe(expectedResMarbles, expectedResultValues);
180
+ });
181
+ });
182
+ it('should handle a successful chunked call', () => {
183
+ // Arrange
184
+ jest.spyOn(service, 'invokeRequest').mockImplementation(() => {
185
+ return from([
186
+ ...makeCallerChunkedMessage([
187
+ chunkRet('[{"'),
188
+ chunkRet('id":1},'),
189
+ ack('a'),
190
+ chunkRet('{"id":2}'),
191
+ ack('b'),
192
+ chunkRet(']'),
193
+ ]),
194
+ makeCallerMessage([ack('c'), ret(3)]),
195
+ makeCallerMessage([res(true)]),
196
+ ]).pipe(concatMap((item) => of(item).pipe(delay(1))));
197
+ });
198
+ testScheduler.run(({ expectObservable }) => {
199
+ // Act
200
+ const call = service.call({
201
+ callsign: 'callsign',
202
+ projectId: 'project',
203
+ source: '/test',
204
+ });
205
+ // Assert
206
+ const expectedAckMarbles = '---a-b-c|';
207
+ const expectedRetMarbles = '------ab|';
208
+ const expectedResMarbles = '--------(a|)';
209
+ const expectedAckValues = {
210
+ a: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'a' },
211
+ b: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'b' },
212
+ c: { stage: Stage.AGENT, status: AckStatus.PROGRESS, message: 'c' },
213
+ };
214
+ const expectedReturnValues = {
215
+ a: [{ id: 1 }, { id: 2 }],
216
+ b: { id: 3 },
217
+ };
218
+ const expectedResultValues = {
219
+ a: { status: ResultStatus.COMPLETE_SUCCESS, message: 'everything is fine' },
220
+ };
221
+ expectObservable(call.ack$).toBe(expectedAckMarbles, expectedAckValues);
222
+ expectObservable(call.return$).toBe(expectedRetMarbles, expectedReturnValues);
223
+ expectObservable(call.result$).toBe(expectedResMarbles, expectedResultValues);
224
+ });
225
+ });
226
+ });
227
+ });