From 250f744e03cf2cd49370a68ab761d9679cc36c98 Mon Sep 17 00:00:00 2001 From: yasaichi Date: Sun, 10 Mar 2024 15:43:08 +0900 Subject: [PATCH] Temporary commit --- deno.lock | 2 + libs/fake-api-kiota/schema.yaml | 6 ++ .../src/fake-api-kiota.module-definition.ts | 6 +- .../src/generated/kiota-lock.json | 2 +- .../src/generated/users/item/index.ts | 35 +++++++- package.json | 6 +- pnpm-lock.yaml | 6 ++ src/app.interceptor.ts | 41 +++++++-- src/app.module.ts | 8 +- .../effective-users.controller.ts | 12 +++ src/effective-users/effective-users.module.ts | 9 ++ .../effective-users.service.test.ts | 86 +++++++++++++++++++ .../effective-users.service.ts | 65 ++++++++++++++ src/simple-users/simple-users.module.ts | 2 - src/simple-users/simple-users.service.test.ts | 2 +- 15 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 src/effective-users/effective-users.controller.ts create mode 100644 src/effective-users/effective-users.module.ts create mode 100644 src/effective-users/effective-users.service.test.ts create mode 100644 src/effective-users/effective-users.service.ts diff --git a/deno.lock b/deno.lock index 47d645e..1b953d9 100644 --- a/deno.lock +++ b/deno.lock @@ -18,10 +18,12 @@ "npm:@nestjs/schematics@^10.0.0", "npm:@nestjs/testing@^10.0.0", "npm:@types/express@^4.17.17", + "npm:@types/http-errors@^2.0.4", "npm:@types/node@^20.3.1", "npm:@types/supertest@^6.0.0", "npm:effect@^2.4.0", "npm:fetch-mock@^9.11.0", + "npm:http-errors@^2.0.0", "npm:reflect-metadata@^0.2.0", "npm:rxjs@^7.8.1", "npm:supertest@^6.3.3" diff --git a/libs/fake-api-kiota/schema.yaml b/libs/fake-api-kiota/schema.yaml index 515feeb..117e6c3 100644 --- a/libs/fake-api-kiota/schema.yaml +++ b/libs/fake-api-kiota/schema.yaml @@ -93,3 +93,9 @@ paths: application/json: schema: $ref: '#/components/schemas/user' + '404': + description: User no found + content: + application/json: + schema: + type: object diff --git a/libs/fake-api-kiota/src/fake-api-kiota.module-definition.ts b/libs/fake-api-kiota/src/fake-api-kiota.module-definition.ts index ab1ed17..284fd56 100644 --- a/libs/fake-api-kiota/src/fake-api-kiota.module-definition.ts +++ b/libs/fake-api-kiota/src/fake-api-kiota.module-definition.ts @@ -2,4 +2,8 @@ import { ConfigurableModuleBuilder } from '@nestjs/common'; import { type FakeApiKiotaModuleOptions } from './interfaces/fake-api-kiota-module-options.interface.ts'; export const { ConfigurableModuleClass, OPTIONS_TYPE } = - new ConfigurableModuleBuilder().build(); + new ConfigurableModuleBuilder() + .setExtras( + { global: true }, + (definition, { global }) => ({ ...definition, global }), + ).build(); diff --git a/libs/fake-api-kiota/src/generated/kiota-lock.json b/libs/fake-api-kiota/src/generated/kiota-lock.json index bf6b723..eaece96 100644 --- a/libs/fake-api-kiota/src/generated/kiota-lock.json +++ b/libs/fake-api-kiota/src/generated/kiota-lock.json @@ -1,5 +1,5 @@ { - "descriptionHash": "0683261456014BDB99EB7499816A79D9C2DEDBF2CC09A63492F5F55BCD573D5109F8B31274FAEB43F6CF71C04276537F2930B3821D71B83D74773043C65DB6FD", + "descriptionHash": "A96356C19D4668CADDEA5EFCD60A9836FDBDA51766A42DACAF06005B42810633B3DFBBC85CF74FCD8F6F102D7CC2811614A8751FA3EEF55AB6BFECBECD8D83B1", "descriptionLocation": "../../schema.yaml", "lockFileVersion": "1.0.0", "kiotaVersion": "1.12.0", diff --git a/libs/fake-api-kiota/src/generated/users/item/index.ts b/libs/fake-api-kiota/src/generated/users/item/index.ts index f54c171..8ce3e6f 100644 --- a/libs/fake-api-kiota/src/generated/users/item/index.ts +++ b/libs/fake-api-kiota/src/generated/users/item/index.ts @@ -2,8 +2,37 @@ /* eslint-disable */ // Generated by Microsoft Kiota import { createUserFromDiscriminatorValue, type User } from '../../models/index.ts'; -import { type BaseRequestBuilder, type Parsable, type ParsableFactory, type RequestConfiguration, type RequestInformation, type RequestsMetadata } from '@microsoft/kiota-abstractions'; +import { type AdditionalDataHolder, type ApiError, type BaseRequestBuilder, type Parsable, type ParsableFactory, type ParseNode, type RequestConfiguration, type RequestInformation, type RequestsMetadata, type SerializationWriter } from '@microsoft/kiota-abstractions'; +/** + * Creates a new instance of the appropriate class based on discriminator value + * @param parseNode The parse node to use to read the discriminator value and create the object + * @returns {User404Error} + */ +export function createUser404ErrorFromDiscriminatorValue(parseNode: ParseNode | undefined) : ((instance?: Parsable) => Record void>) { + return deserializeIntoUser404Error; +} +/** + * The deserialization information for the current model + * @returns {Record void>} + */ +export function deserializeIntoUser404Error(user404Error: Partial | undefined = {}) : Record void> { + return { + } +} +/** + * Serializes information the current object + * @param writer Serialization writer to use to serialize this model + */ +export function serializeUser404Error(writer: SerializationWriter, user404Error: Partial | undefined = {}) : void { + writer.writeAdditionalData(user404Error.additionalData); +} +export interface User404Error extends AdditionalDataHolder, ApiError, Parsable { + /** + * Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + */ + additionalData?: Record; +} /** * Builds and executes requests for operations under /users/{user-id} */ @@ -12,6 +41,7 @@ export interface UserItemRequestBuilder extends BaseRequestBuilder} + * @throws {User404Error} error when the service returns a 404 status code */ get(requestConfiguration?: RequestConfiguration | undefined) : Promise; /** @@ -32,6 +62,9 @@ export const UserItemRequestBuilderRequestsMetadata: RequestsMetadata = { get: { uriTemplate: UserItemRequestBuilderUriTemplate, responseBodyContentType: "application/json", + errorMappings: { + 404: createUser404ErrorFromDiscriminatorValue as ParsableFactory, + }, adapterMethodName: "send", responseBodyFactory: createUserFromDiscriminatorValue, }, diff --git a/package.json b/package.json index f2b9911..d2c7b8f 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ "start:debug": "deno run --allow-env --allow-net --allow-read=. --inspect=127.0.0.1:9229 --watch src/main.ts", "start:prod": "deno run --allow-env --allow-net --allow-read=. src/main.ts", "lint": "deno lint", - "test": "deno test --allow-env --ignore=test/ --parallel", + "test": "deno test --allow-env --allow-read=. --ignore=test/ --parallel", "test:watch": "deno task test --watch", "test:cov": "deno task test --coverage && deno coverage --exclude='/generated/' && deno coverage --exclude='/generated/' --lcov --output=coverage/lcov.info", - "test:debug": "deno test --inspect-brk=127.0.0.1:9230", + "test:debug": "deno test --allow-env --allow-read=. --inspect-brk=127.0.0.1:9230", "test:e2e": "deno test --allow-env --allow-net --allow-read=. --parallel test/" }, "dependencies": { @@ -30,6 +30,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "effect": "^2.4.0", + "http-errors": "^2.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, @@ -39,6 +40,7 @@ "@nestjs/testing": "^10.0.0", "@std/testing": "npm:@jsr/std__testing@^0.218.2", "@types/express": "^4.17.17", + "@types/http-errors": "^2.0.4", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", "fetch-mock": "^9.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8233e9c..7954c1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ dependencies: effect: specifier: ^2.4.0 version: 2.4.0 + http-errors: + specifier: ^2.0.0 + version: 2.0.0 reflect-metadata: specifier: ^0.2.0 version: 0.2.1 @@ -58,6 +61,9 @@ devDependencies: '@types/express': specifier: ^4.17.17 version: 4.17.21 + '@types/http-errors': + specifier: ^2.0.4 + version: 2.0.4 '@types/node': specifier: ^20.3.1 version: 20.11.20 diff --git a/src/app.interceptor.ts b/src/app.interceptor.ts index dabcb43..353066e 100644 --- a/src/app.interceptor.ts +++ b/src/app.interceptor.ts @@ -2,14 +2,22 @@ import { CallHandler, ExecutionContext, Injectable, + Logger, NestInterceptor, } from '@nestjs/common'; -import { Effect } from 'effect'; +import { Effect, identity, Match, unsafeCoerce } from 'effect'; +import createError from 'http-errors'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @Injectable() export class AppInterceptor implements NestInterceptor { + private readonly logger: Logger; + + constructor() { + this.logger = new Logger(AppInterceptor.name); + } + intercept( _context: ExecutionContext, next: CallHandler, @@ -18,11 +26,32 @@ export class AppInterceptor implements NestInterceptor { .handle() .pipe( map((value) => - // NOTE: `_op` is a special key that indicates that the value is an effect - // https://github.com/Effect-TS/effect/blob/12345bf7955d126d4f3f48b604ed1f86da744148/packages/effect/src/internal/fiberRuntime.ts#L1282 - typeof value === 'object' && '_op' in value - ? Effect.runPromise(value) - : value + Match.value(value).pipe( + Match.when(() => Effect.isEffect(value), async (effect) => + Match.value( + await Effect.runPromiseExit( + unsafeCoerce(effect), + ), + ).pipe( + Match.tag('Success', (success) => + success.value), + Match.tag( + 'Failure', + (failure) => + Match.value(failure.cause).pipe( + Match.tag('Fail', ({ error }) => { + throw error; + }), + Match.orElse((anotherFailure) => { + this.logger.error(anotherFailure); + throw createError(500); + }), + ), + ), + Match.exhaustive, + )), + Match.orElse(identity), + ) ), ); } diff --git a/src/app.module.ts b/src/app.module.ts index 3731080..db6a942 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,12 +1,18 @@ +import { FakeApiKiotaModule } from '@app/fake-api-kiota'; import { Module } from '@nestjs/common'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { AppController } from './app.controller.ts'; import { AppInterceptor } from './app.interceptor.ts'; import { AppService } from './app.service.ts'; +import { EffectiveUsersModule } from './effective-users/effective-users.module.ts'; import { SimpleUsersModule } from './simple-users/simple-users.module.ts'; @Module({ - imports: [SimpleUsersModule], + imports: [ + FakeApiKiotaModule.register({ global: true }), + EffectiveUsersModule, + SimpleUsersModule, + ], controllers: [AppController], providers: [AppService, { provide: APP_INTERCEPTOR, diff --git a/src/effective-users/effective-users.controller.ts b/src/effective-users/effective-users.controller.ts new file mode 100644 index 0000000..3e5005e --- /dev/null +++ b/src/effective-users/effective-users.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { EffectiveUsersService } from './effective-users.service.ts'; + +@Controller('effective/users') +export class EffectiveUsersController { + constructor(private readonly effectiveUsersService: EffectiveUsersService) {} + + @Get(':id') + findOne(@Param('id') id: number) { + return this.effectiveUsersService.findOneWithLatestPosts(id); + } +} diff --git a/src/effective-users/effective-users.module.ts b/src/effective-users/effective-users.module.ts new file mode 100644 index 0000000..8f1622f --- /dev/null +++ b/src/effective-users/effective-users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { EffectiveUsersController } from './effective-users.controller.ts'; +import { EffectiveUsersService } from './effective-users.service.ts'; + +@Module({ + controllers: [EffectiveUsersController], + providers: [EffectiveUsersService], +}) +export class EffectiveUsersModule {} diff --git a/src/effective-users/effective-users.service.test.ts b/src/effective-users/effective-users.service.test.ts new file mode 100644 index 0000000..d5c49ce --- /dev/null +++ b/src/effective-users/effective-users.service.test.ts @@ -0,0 +1,86 @@ +import { FakeApiKiotaModule, type Post, type User } from '@app/fake-api-kiota'; +import { Test, TestingModule } from '@nestjs/testing'; +import { beforeEach, describe, it } from '@std/testing/bdd'; +import { Effect } from 'effect'; +import fetchMock from 'fetch-mock'; +import createHttpError from 'http-errors'; +import assert from 'node:assert'; +import { EffectiveUsersService } from './effective-users.service.ts'; + +describe(EffectiveUsersService.name, () => { + let service: EffectiveUsersService; + + describe(EffectiveUsersService.prototype.findOneWithLatestPosts.name, () => { + const userId = 42; + const user: User = { + id: userId, + name: 'Foo Bar', + username: 'foobar', + email: 'foo.bar@example.com', + }; + const posts: Post[] = [{ + id: 1, + userId, + title: 'post 1 title', + body: 'post 1 body', + }]; + + describe('when the user is not found', () => { + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + FakeApiKiotaModule.register({ + customFetch: fetchMock + .sandbox() + .get(`path:/users/${userId}`, { body: {}, status: 404 }) + .getOnce('path:/posts', posts, { query: { userId, limit: 5 } }), + }), + ], + providers: [EffectiveUsersService], + }).compile(); + + service = module.get(EffectiveUsersService); + }); + + it('should throw an error equivalent to HTTP 404 error', () => { + assert.rejects( + Effect.runPromise( + service.findOneWithLatestPosts(userId).pipe( + Effect.withConcurrency('unbounded'), + ), + ), + createHttpError(404), + ); + }); + }); + + describe('when the user is found', () => { + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + FakeApiKiotaModule.register({ + customFetch: fetchMock + .sandbox() + .get(`path:/users/${userId}`, user) + .getOnce('path:/posts', posts, { query: { userId, limit: 5 } }), + }), + ], + providers: [EffectiveUsersService], + }).compile(); + + service = module.get(EffectiveUsersService); + }); + + it('should return a specified user with the latest posts', async () => { + assert.deepStrictEqual( + await Effect.runPromise(service.findOneWithLatestPosts(userId)), + { + id: user.id, + username: user.username, + latestPosts: [{ id: posts[0].id, title: posts[0].title }], + }, + ); + }); + }); + }); +}); diff --git a/src/effective-users/effective-users.service.ts b/src/effective-users/effective-users.service.ts new file mode 100644 index 0000000..d9159c9 --- /dev/null +++ b/src/effective-users/effective-users.service.ts @@ -0,0 +1,65 @@ +import { + FAKE_API_KIOTA_SERVICE_TOKEN, + type FakeApiService, +} from '@app/fake-api-kiota'; +import { Inject, Injectable } from '@nestjs/common'; +import { Effect, identity, Match, Schedule } from 'effect'; +import createError from 'http-errors'; + +@Injectable() +export class EffectiveUsersService { + constructor( + @Inject(FAKE_API_KIOTA_SERVICE_TOKEN) private readonly apiService: + FakeApiService, + ) {} + + findOneWithLatestPosts(id: number) { + return Effect.all( + [ + Effect.retry( + Effect.tryPromise(() => this.apiService.users.byUserId(id).get()), + { + schedule: Schedule.exponential('100 millis'), + times: 3, + until: ({ error }) => + Match.value(error).pipe( + Match.when({ responseStatusCode: 404 }, () => true), + Match.orElse(() => false), + ), + }, + ), + Effect.retry( + Effect.tryPromise(() => + this.apiService.posts.get({ + queryParameters: { userId: id, limit: 5 }, + }) + ), + { schedule: Schedule.exponential('100 millis'), times: 3 }, + ), + ], + { concurrency: 'unbounded' }, + ).pipe( + Effect.flatMap(([user, posts]) => + !user || !posts + ? Effect.die('Something went wrong!') + : Effect.succeed([user, posts] as const) + ), + Effect.map(([user, posts]) => ({ + id: user.id, + username: user.username, + latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), + })), + Effect.catchAll(({ error }) => + Effect.fail( + Match.value(error).pipe( + Match.when( + { responseStatusCode: Match.number }, + ({ responseStatusCode }) => createError(responseStatusCode), + ), + Match.orElse(identity), + ), + ) + ), + ); + } +} diff --git a/src/simple-users/simple-users.module.ts b/src/simple-users/simple-users.module.ts index d3b0c66..b1c697d 100644 --- a/src/simple-users/simple-users.module.ts +++ b/src/simple-users/simple-users.module.ts @@ -1,10 +1,8 @@ -import { FakeApiKiotaModule } from '@app/fake-api-kiota'; import { Module } from '@nestjs/common'; import { SimpleUsersController } from './simple-users.controller.ts'; import { SimpleUsersService } from './simple-users.service.ts'; @Module({ - imports: [FakeApiKiotaModule.register({})], controllers: [SimpleUsersController], providers: [SimpleUsersService], }) diff --git a/src/simple-users/simple-users.service.test.ts b/src/simple-users/simple-users.service.test.ts index 59132d6..cec811a 100644 --- a/src/simple-users/simple-users.service.test.ts +++ b/src/simple-users/simple-users.service.test.ts @@ -8,7 +8,7 @@ import { SimpleUsersService } from './simple-users.service.ts'; describe(SimpleUsersService.name, () => { let service: SimpleUsersService; - describe('#findOneWithLatestPosts()', () => { + describe(SimpleUsersService.prototype.findOneWithLatestPosts.name, () => { const userId = 42; const user: User = { id: userId,