HATEOAS-first backend framework.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. import {
  2. beforeAll,
  3. afterAll,
  4. afterEach,
  5. beforeEach,
  6. describe,
  7. expect,
  8. it,
  9. } from 'vitest';
  10. import {
  11. tmpdir
  12. } from 'os';
  13. import {
  14. mkdtemp,
  15. rm,
  16. writeFile,
  17. } from 'fs/promises';
  18. import {
  19. join
  20. } from 'path';
  21. import {
  22. application,
  23. DataSource,
  24. dataSources,
  25. encodings,
  26. Resource,
  27. resource,
  28. serializers,
  29. valibot as v,
  30. } from '../../src';
  31. import {request, Server} from 'http';
  32. import {constants} from 'http2';
  33. const PORT = 3000;
  34. const HOST = 'localhost';
  35. const ACCEPT_ENCODING = 'utf-8';
  36. const ACCEPT = 'application/json';
  37. const autoIncrement = async (dataSource: DataSource) => {
  38. const data = await dataSource.getMultiple() as Record<string, string>[];
  39. const highestId = data.reduce<number>(
  40. (highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId),
  41. -Infinity
  42. );
  43. if (Number.isFinite(highestId)) {
  44. return (highestId + 1);
  45. }
  46. return 1;
  47. };
  48. describe('yasumi', () => {
  49. let baseDir: string;
  50. beforeAll(async () => {
  51. try {
  52. baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
  53. } catch {
  54. // noop
  55. }
  56. });
  57. afterAll(async () => {
  58. try {
  59. await rm(baseDir, {
  60. recursive: true,
  61. });
  62. } catch {
  63. // noop
  64. }
  65. });
  66. let Piano: Resource;
  67. beforeEach(() => {
  68. Piano = resource(v.object(
  69. {
  70. brand: v.string()
  71. },
  72. v.never()
  73. ))
  74. .name('Piano')
  75. .id('id', {
  76. generationStrategy: autoIncrement,
  77. serialize: (id) => id?.toString() ?? '0',
  78. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  79. })
  80. });
  81. let server: Server;
  82. beforeEach(() => {
  83. const app = application({
  84. name: 'piano-service',
  85. dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir),
  86. })
  87. .contentType(ACCEPT, serializers.applicationJson)
  88. .encoding(ACCEPT_ENCODING, encodings.utf8)
  89. .resource(Piano);
  90. server = app.createServer({
  91. baseUrl: '/api'
  92. });
  93. return new Promise((resolve, reject) => {
  94. server.on('error', (err) => {
  95. reject(err);
  96. });
  97. server.on('listening', () => {
  98. resolve();
  99. });
  100. server.listen({
  101. port: PORT
  102. });
  103. });
  104. });
  105. afterEach(() => new Promise((resolve, reject) => {
  106. server.close((err) => {
  107. if (err) {
  108. reject(err);
  109. }
  110. resolve();
  111. });
  112. }));
  113. describe('serving collections', () => {
  114. it('returns data', () => {
  115. return new Promise<void>((resolve, reject) => {
  116. Piano.allowFetchCollection();
  117. const req = request(
  118. {
  119. host: HOST,
  120. port: PORT,
  121. path: '/api/pianos',
  122. method: 'GET',
  123. headers: {
  124. 'Accept': ACCEPT,
  125. 'Accept-Encoding': ACCEPT_ENCODING,
  126. },
  127. },
  128. (res) => {
  129. res.on('error', (err) => {
  130. Piano.revokeFetchCollection();
  131. reject(err);
  132. });
  133. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  134. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  135. let resBuffer = Buffer.from('');
  136. res.on('data', (c) => {
  137. resBuffer = Buffer.concat([resBuffer, c]);
  138. });
  139. res.on('close', () => {
  140. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  141. const resData = JSON.parse(resBufferJson);
  142. expect(resData).toEqual([]);
  143. Piano.revokeFetchCollection();
  144. resolve();
  145. });
  146. },
  147. );
  148. req.on('error', (err) => {
  149. Piano.revokeFetchCollection();
  150. reject(err);
  151. });
  152. req.end();
  153. });
  154. });
  155. });
  156. describe('serving items', () => {
  157. const data = {
  158. id: 1,
  159. brand: 'Yamaha'
  160. };
  161. beforeEach(async () => {
  162. const resourcePath = join(baseDir, 'pianos.jsonl');
  163. await writeFile(resourcePath, JSON.stringify(data));
  164. });
  165. it('returns data', () => {
  166. return new Promise<void>((resolve, reject) => {
  167. Piano.allowFetchItem();
  168. const req = request(
  169. {
  170. host: HOST,
  171. port: PORT,
  172. path: '/api/pianos/1',
  173. method: 'GET',
  174. headers: {
  175. 'Accept': ACCEPT,
  176. 'Accept-Encoding': ACCEPT_ENCODING,
  177. },
  178. },
  179. (res) => {
  180. res.on('error', (err) => {
  181. Piano.revokeFetchItem();
  182. reject(err);
  183. });
  184. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  185. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  186. let resBuffer = Buffer.from('');
  187. res.on('data', (c) => {
  188. resBuffer = Buffer.concat([resBuffer, c]);
  189. });
  190. res.on('close', () => {
  191. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  192. const resData = JSON.parse(resBufferJson);
  193. expect(resData).toEqual(data);
  194. Piano.revokeFetchItem();
  195. resolve();
  196. });
  197. },
  198. );
  199. req.on('error', (err) => {
  200. Piano.revokeFetchItem();
  201. reject(err);
  202. });
  203. req.end();
  204. });
  205. });
  206. it('throws on item not found', () => {
  207. return new Promise<void>((resolve, reject) => {
  208. Piano.allowFetchItem();
  209. const req = request(
  210. {
  211. host: HOST,
  212. port: PORT,
  213. path: '/api/pianos/2',
  214. method: 'GET',
  215. headers: {
  216. 'Accept': ACCEPT,
  217. 'Accept-Encoding': ACCEPT_ENCODING,
  218. },
  219. },
  220. (res) => {
  221. res.on('error', (err) => {
  222. Piano.revokeFetchItem();
  223. reject(err);
  224. });
  225. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  226. Piano.revokeFetchItem();
  227. resolve();
  228. },
  229. );
  230. req.on('error', (err) => {
  231. Piano.revokeFetchItem();
  232. reject(err);
  233. });
  234. req.end();
  235. });
  236. });
  237. });
  238. describe('creating items', () => {
  239. const data = {
  240. id: 1,
  241. brand: 'Yamaha'
  242. };
  243. const newData = {
  244. brand: 'K. Kawai'
  245. };
  246. beforeEach(async () => {
  247. const resourcePath = join(baseDir, 'pianos.jsonl');
  248. await writeFile(resourcePath, JSON.stringify(data));
  249. });
  250. // FIXME ID de/serialization problems
  251. it('returns data', () => {
  252. return new Promise<void>((resolve, reject) => {
  253. Piano.allowCreate();
  254. const req = request(
  255. {
  256. host: HOST,
  257. port: PORT,
  258. path: '/api/pianos',
  259. method: 'POST',
  260. headers: {
  261. 'Accept': ACCEPT,
  262. 'Accept-Encoding': ACCEPT_ENCODING,
  263. 'Content-Type': ACCEPT,
  264. },
  265. },
  266. (res) => {
  267. res.on('error', (err) => {
  268. Piano.revokeCreate();
  269. reject(err);
  270. });
  271. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  272. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  273. let resBuffer = Buffer.from('');
  274. res.on('data', (c) => {
  275. resBuffer = Buffer.concat([resBuffer, c]);
  276. });
  277. res.on('close', () => {
  278. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  279. const resData = JSON.parse(resBufferJson);
  280. expect(resData).toEqual({
  281. ...newData,
  282. id: '2'
  283. });
  284. Piano.revokeCreate();
  285. resolve();
  286. });
  287. },
  288. );
  289. req.on('error', (err) => {
  290. Piano.revokeCreate();
  291. reject(err);
  292. });
  293. req.write(JSON.stringify(newData));
  294. req.end();
  295. });
  296. });
  297. });
  298. describe('patching items', () => {
  299. const data = {
  300. id: 1,
  301. brand: 'Yamaha'
  302. };
  303. const newData = {
  304. brand: 'K. Kawai'
  305. };
  306. beforeEach(async () => {
  307. const resourcePath = join(baseDir, 'pianos.jsonl');
  308. await writeFile(resourcePath, JSON.stringify(data));
  309. });
  310. it('returns data', () => {
  311. return new Promise<void>((resolve, reject) => {
  312. Piano.allowPatch();
  313. const req = request(
  314. {
  315. host: HOST,
  316. port: PORT,
  317. path: '/api/pianos/1',
  318. method: 'PATCH',
  319. headers: {
  320. 'Accept': ACCEPT,
  321. 'Accept-Encoding': ACCEPT_ENCODING,
  322. 'Content-Type': ACCEPT,
  323. },
  324. },
  325. (res) => {
  326. res.on('error', (err) => {
  327. Piano.revokePatch();
  328. reject(err);
  329. });
  330. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  331. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  332. let resBuffer = Buffer.from('');
  333. res.on('data', (c) => {
  334. resBuffer = Buffer.concat([resBuffer, c]);
  335. });
  336. res.on('close', () => {
  337. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  338. const resData = JSON.parse(resBufferJson);
  339. expect(resData).toEqual({
  340. ...data,
  341. ...newData,
  342. });
  343. Piano.revokePatch();
  344. resolve();
  345. });
  346. },
  347. );
  348. req.on('error', (err) => {
  349. Piano.revokePatch();
  350. reject(err);
  351. });
  352. req.write(JSON.stringify(newData));
  353. req.end();
  354. });
  355. });
  356. it('throws on item to patch not found', () => {
  357. return new Promise<void>((resolve, reject) => {
  358. Piano.allowPatch();
  359. const req = request(
  360. {
  361. host: HOST,
  362. port: PORT,
  363. path: '/api/pianos/2',
  364. method: 'PATCH',
  365. headers: {
  366. 'Accept': ACCEPT,
  367. 'Accept-Encoding': ACCEPT_ENCODING,
  368. 'Content-Type': ACCEPT,
  369. },
  370. },
  371. (res) => {
  372. res.on('error', (err) => {
  373. Piano.revokePatch();
  374. reject(err);
  375. });
  376. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  377. Piano.revokePatch();
  378. resolve();
  379. },
  380. );
  381. req.on('error', (err) => {
  382. Piano.revokePatch();
  383. reject(err);
  384. });
  385. req.write(JSON.stringify(newData));
  386. req.end();
  387. });
  388. });
  389. });
  390. describe('emplacing items', () => {
  391. const data = {
  392. id: 1,
  393. brand: 'Yamaha'
  394. };
  395. const newData = {
  396. id: 1,
  397. brand: 'K. Kawai'
  398. };
  399. beforeEach(async () => {
  400. const resourcePath = join(baseDir, 'pianos.jsonl');
  401. await writeFile(resourcePath, JSON.stringify(data));
  402. });
  403. // FIXME IDs not properly being de/serialized
  404. it('returns data for replacement', () => {
  405. return new Promise<void>((resolve, reject) => {
  406. Piano.allowEmplace();
  407. const req = request(
  408. {
  409. host: HOST,
  410. port: PORT,
  411. path: '/api/pianos/1',
  412. method: 'PUT',
  413. headers: {
  414. 'Accept': ACCEPT,
  415. 'Accept-Encoding': ACCEPT_ENCODING,
  416. 'Content-Type': ACCEPT,
  417. },
  418. },
  419. (res) => {
  420. res.on('error', (err) => {
  421. Piano.revokeEmplace();
  422. reject(err);
  423. });
  424. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  425. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  426. let resBuffer = Buffer.from('');
  427. res.on('data', (c) => {
  428. resBuffer = Buffer.concat([resBuffer, c]);
  429. });
  430. res.on('close', () => {
  431. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  432. const resData = JSON.parse(resBufferJson);
  433. expect(resData).toEqual(newData);
  434. Piano.revokeEmplace();
  435. resolve();
  436. });
  437. },
  438. );
  439. req.on('error', (err) => {
  440. Piano.revokeEmplace();
  441. reject(err);
  442. });
  443. req.write(JSON.stringify(newData));
  444. req.end();
  445. });
  446. });
  447. it('returns data for creation', () => {
  448. return new Promise<void>((resolve, reject) => {
  449. const id = 2;
  450. Piano.allowEmplace();
  451. const req = request(
  452. {
  453. host: HOST,
  454. port: PORT,
  455. path: `/api/pianos/${id}`,
  456. method: 'PUT',
  457. headers: {
  458. 'Accept': ACCEPT,
  459. 'Accept-Encoding': ACCEPT_ENCODING,
  460. 'Content-Type': ACCEPT,
  461. },
  462. },
  463. (res) => {
  464. res.on('error', (err) => {
  465. Piano.revokeEmplace();
  466. reject(err);
  467. });
  468. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  469. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  470. let resBuffer = Buffer.from('');
  471. res.on('data', (c) => {
  472. resBuffer = Buffer.concat([resBuffer, c]);
  473. });
  474. res.on('close', () => {
  475. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  476. const resData = JSON.parse(resBufferJson);
  477. expect(resData).toEqual({
  478. ...newData,
  479. id,
  480. });
  481. Piano.revokeEmplace();
  482. resolve();
  483. });
  484. },
  485. );
  486. req.on('error', (err) => {
  487. Piano.revokeEmplace();
  488. reject(err);
  489. });
  490. req.write(JSON.stringify({
  491. ...newData,
  492. id,
  493. }));
  494. req.end();
  495. });
  496. });
  497. });
  498. describe('deleting items', () => {
  499. const data = {
  500. id: 1,
  501. brand: 'Yamaha'
  502. };
  503. beforeEach(async () => {
  504. const resourcePath = join(baseDir, 'pianos.jsonl');
  505. await writeFile(resourcePath, JSON.stringify(data));
  506. });
  507. it('returns data', () => {
  508. return new Promise<void>((resolve, reject) => {
  509. Piano.allowDelete();
  510. const req = request(
  511. {
  512. host: HOST,
  513. port: PORT,
  514. path: '/api/pianos/1',
  515. method: 'DELETE',
  516. headers: {
  517. 'Accept': ACCEPT,
  518. 'Accept-Encoding': ACCEPT_ENCODING,
  519. },
  520. },
  521. (res) => {
  522. res.on('error', (err) => {
  523. Piano.revokeDelete();
  524. reject(err);
  525. });
  526. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  527. Piano.revokeDelete();
  528. resolve();
  529. },
  530. );
  531. req.on('error', (err) => {
  532. Piano.revokeDelete();
  533. reject(err);
  534. });
  535. req.end();
  536. });
  537. });
  538. it('throws on item not found', () => {
  539. return new Promise<void>((resolve, reject) => {
  540. Piano.allowDelete();
  541. const req = request(
  542. {
  543. host: HOST,
  544. port: PORT,
  545. path: '/api/pianos/2',
  546. method: 'DELETE',
  547. headers: {
  548. 'Accept': ACCEPT,
  549. 'Accept-Encoding': ACCEPT_ENCODING,
  550. },
  551. },
  552. (res) => {
  553. res.on('error', (err) => {
  554. Piano.revokeDelete();
  555. reject(err);
  556. });
  557. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  558. Piano.revokeDelete();
  559. resolve();
  560. },
  561. );
  562. req.on('error', (err) => {
  563. Piano.revokeDelete();
  564. reject(err);
  565. });
  566. req.end();
  567. });
  568. });
  569. });
  570. });