From c9017ec6cd4686fc4bca3b706cc21e2a6c9e950d Mon Sep 17 00:00:00 2001 From: jonas lagoni Date: Wed, 25 Dec 2024 16:43:14 +0100 Subject: [PATCH 01/19] add http client --- docs/inputs/asyncapi.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/inputs/asyncapi.md b/docs/inputs/asyncapi.md index 797e5f86..4b2895fb 100644 --- a/docs/inputs/asyncapi.md +++ b/docs/inputs/asyncapi.md @@ -36,6 +36,9 @@ To customize the code generation through the AsyncAPI document, use the `x-the-c } ``` +### HTTP + + ## FAQ ### How does it relate to AsyncAPI Generator and templates? From 3ff73a0f591efe0007c2feab2a3ecb34088f7879 Mon Sep 17 00:00:00 2001 From: jonas lagoni Date: Wed, 25 Dec 2024 16:43:32 +0100 Subject: [PATCH 02/19] add http client --- docs/protocols/http.md | 44 ++ test/runtime/http/helpers.ts | 71 ++++ test/runtime/http/openapi.yaml | 741 +++++++++++++++++++++++++++++++++ 3 files changed, 856 insertions(+) create mode 100644 docs/protocols/http.md create mode 100644 test/runtime/http/helpers.ts create mode 100644 test/runtime/http/openapi.yaml diff --git a/docs/protocols/http.md b/docs/protocols/http.md new file mode 100644 index 00000000..51910898 --- /dev/null +++ b/docs/protocols/http.md @@ -0,0 +1,44 @@ +--- +sidebar_position: 99 +--- + +# HTTP(S) + +Both client and server generator is available. + +It is currently available through the generators ([channels](../generators/channels.md)): + +All of this is available through [AsyncAPI](../inputs/asyncapi.md). + +## TypeScript + +## Server features + +| **Feature** | Client | Server | +|---|---|---| +| Download | ➗ | ➗ | +| Upload | ➗ | ➗ | +| Offset based Pagination | ➗ | ➗ | +| Cursor based Pagination | ➗ | ➗ | +| Page based Pagination | ➗ | ➗ | +| Time based Pagination | ➗ | ➗ | +| Keyset based Pagination | ➗ | ➗ | +| Retry with backoff | ➗ | ➗ | +| OAuth2 Authorization code | ➗ | ➗ | +| OAuth2 Implicit | ➗ | ➗ | +| OAuth2 password | ➗ | ➗ | +| OAuth2 Client Credentials | ➗ | ➗ | +| Username/password Authentication | ➗ | ➗ | +| Bearer Authentication | ➗ | ➗ | +| Basic Authentication | ➗ | ➗ | +| API Key Authentication | ➗ | ➗ | +| JSON Based API | ➗ | ➗ | +| POST | ➗ | ➗ | +| GET | ➗ | ➗ | +| PATCH | ➗ | ➗ | +| DELETE | ➗ | ➗ | +| PUT | ➗ | ➗ | +| HEAD | ➗ | ➗ | +| OPTIONS | ➗ | ➗ | +| TRACE | ➗ | ➗ | +| CONNECT | ➗ | ➗ | diff --git a/test/runtime/http/helpers.ts b/test/runtime/http/helpers.ts new file mode 100644 index 00000000..7fc29b9f --- /dev/null +++ b/test/runtime/http/helpers.ts @@ -0,0 +1,71 @@ + +export type Json = any; +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +export type HTTPHeaders = { [key: string]: string }; +export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; +export type HTTPBody = Json | FormData | URLSearchParams; +export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody }; +type stringPromise = string | Promise; + +interface RequestContext { + requestParams: RequestParams, + postPayload: RequestPayload, + basePath?: string | 'http://localhost:3000'; // override base path + username?: string; // parameter for basic security + password?: string; // parameter for basic security + apiKey?: stringPromise | ((name: string) => stringPromise); // parameter for apiKey security + accessToken?: stringPromise | ((name?: string, scopes?: string[]) => stringPromise); // parameter for oauth2 security + credentials?: RequestCredentials; //value for the credentials param we want to use on each request + additionalHeaders?: HTTPHeaders; //header params we want to use on every request +} +type StatusCodes = 400 | 500; +export class FetchError extends Error { + override name: "FetchError" = "FetchError"; + code: StatusCodes + constructor(public cause: Error, code: StatusCodes) { + let msg: string = 'unknown'; + if(code === 400) { + msg = 'Pet not found' + } + super(msg); + this.code = code + } +} + +async function addPet(context: RequestContext): Promise { + const headers = Object.assign({ + 'Content-Type': 'application/json' + }, context.additionalHeaders); + let url = context.basePath + '/pet'; + + let body: any; + if(context.postPayload) { + body = context.postPayload.marshal(); + } + if (context.accessToken) { + // oauth required + headers["Authorization"] = typeof context.accessToken === 'string' ? context.accessToken : await context.accessToken("petstore_auth", ["write:pets", "read:pets"]); + } + + const response = await fetch('/pet', { + method: 'POST', + headers: headers, + body: '{}', + credentials: context.credentials, + }) + if(response.status === 401) { + return Promise.reject(new FetchError(response, response.status)) + } else if { + + } + const { data, errors } = await response.json() + if (response.ok) { + return ReplyMessage.marshal(data) + } else { + // handle the graphql errors + const error = new Error( + errors?.map((e) => e.message).join('\n') ?? 'unknown', + ) + return Promise.reject(error); + } +} \ No newline at end of file diff --git a/test/runtime/http/openapi.yaml b/test/runtime/http/openapi.yaml new file mode 100644 index 00000000..a8f9809a --- /dev/null +++ b/test/runtime/http/openapi.yaml @@ -0,0 +1,741 @@ +openapi: 3.0.0 +servers: + - url: 'http://petstore.swagger.io/v2' +info: + description: >- + This is a sample server Petstore server. For this sample, you can use the api key + `special-key` to test the authorization filters. + version: 1.0.0 + title: OpenAPI Petstore + license: + name: Apache-2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user +paths: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: '' + operationId: addPet + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + put: + tags: + - pet + summary: Update an existing pet + description: '' + operationId: updatePet + externalDocs: + url: "http://petstore.swagger.io/v2/doc/updatePet" + description: "API documentation for the updatePet operation" + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + style: form + explode: false + deprecated: true + schema: + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - 'read:pets' + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + style: form + explode: false + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid tag value + security: + - petstore_auth: + - 'read:pets' + deprecated: true + '/pet/{petId}': + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + description: Updated name of the pet + type: string + status: + description: Updated status of the pet + type: string + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid pet value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}/uploadImage': + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + file: + description: file to upload + type: string + format: binary + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: '' + operationId: placeOrder + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid Order + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: order placed for purchasing the pet + required: true + '/store/order/{orderId}': + get: + tags: + - store + summary: Find purchase order by ID + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + format: int64 + minimum: 1 + maximum: 5 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Created user object + required: true + /user/createWithArray: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithArrayInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithListInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + - name: password + in: query + description: The password for login in clear text + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: + Set-Cookie: + description: >- + Cookie authentication key for use with the `api_key` + apiKey authentication. + schema: + type: string + example: AUTH_KEY=abcde12345; Path=/; HttpOnly + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + responses: + default: + description: successful operation + security: + - api_key: [] + '/user/{username}': + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing. + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Updated user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid user supplied + '404': + description: User not found + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Updated user object + required: true + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found + security: + - api_key: [] +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' +components: + requestBodies: + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: List of user object + required: true + Pet: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + description: Pet object that needs to be added to the store + required: true + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header + schemas: + Order: + title: Pet Order + description: An order for a pets from the pet store + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + default: false + xml: + name: Order + Category: + title: Pet category + description: A category for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + xml: + name: Category + User: + title: a User + description: A User who is purchasing from the pet store + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User + Tag: + title: Pet Tag + description: A tag for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Pet: + title: a Pet + description: A pet for sale in the pet store + type: object + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + category: + $ref: '#/components/schemas/Category' + name: + type: string + example: doggie + photoUrls: + type: array + xml: + name: photoUrl + wrapped: true + items: + type: string + tags: + type: array + xml: + name: tag + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + deprecated: true + enum: + - available + - pending + - sold + xml: + name: Pet + ApiResponse: + title: An uploaded response + description: Describes the result of uploading an image resource + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string From 693ec78a8faf904415c8cee38908de9d07c1e5fe Mon Sep 17 00:00:00 2001 From: jonas lagoni Date: Wed, 25 Dec 2024 16:47:14 +0100 Subject: [PATCH 03/19] add http client --- .../channels/protocols/http/helpers.ts | 71 ++ .../channels/protocols/http/openapi.yaml | 741 ++++++++++++++++++ 2 files changed, 812 insertions(+) create mode 100644 src/codegen/generators/typescript/channels/protocols/http/helpers.ts create mode 100644 src/codegen/generators/typescript/channels/protocols/http/openapi.yaml diff --git a/src/codegen/generators/typescript/channels/protocols/http/helpers.ts b/src/codegen/generators/typescript/channels/protocols/http/helpers.ts new file mode 100644 index 00000000..7fc29b9f --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/helpers.ts @@ -0,0 +1,71 @@ + +export type Json = any; +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +export type HTTPHeaders = { [key: string]: string }; +export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; +export type HTTPBody = Json | FormData | URLSearchParams; +export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody }; +type stringPromise = string | Promise; + +interface RequestContext { + requestParams: RequestParams, + postPayload: RequestPayload, + basePath?: string | 'http://localhost:3000'; // override base path + username?: string; // parameter for basic security + password?: string; // parameter for basic security + apiKey?: stringPromise | ((name: string) => stringPromise); // parameter for apiKey security + accessToken?: stringPromise | ((name?: string, scopes?: string[]) => stringPromise); // parameter for oauth2 security + credentials?: RequestCredentials; //value for the credentials param we want to use on each request + additionalHeaders?: HTTPHeaders; //header params we want to use on every request +} +type StatusCodes = 400 | 500; +export class FetchError extends Error { + override name: "FetchError" = "FetchError"; + code: StatusCodes + constructor(public cause: Error, code: StatusCodes) { + let msg: string = 'unknown'; + if(code === 400) { + msg = 'Pet not found' + } + super(msg); + this.code = code + } +} + +async function addPet(context: RequestContext): Promise { + const headers = Object.assign({ + 'Content-Type': 'application/json' + }, context.additionalHeaders); + let url = context.basePath + '/pet'; + + let body: any; + if(context.postPayload) { + body = context.postPayload.marshal(); + } + if (context.accessToken) { + // oauth required + headers["Authorization"] = typeof context.accessToken === 'string' ? context.accessToken : await context.accessToken("petstore_auth", ["write:pets", "read:pets"]); + } + + const response = await fetch('/pet', { + method: 'POST', + headers: headers, + body: '{}', + credentials: context.credentials, + }) + if(response.status === 401) { + return Promise.reject(new FetchError(response, response.status)) + } else if { + + } + const { data, errors } = await response.json() + if (response.ok) { + return ReplyMessage.marshal(data) + } else { + // handle the graphql errors + const error = new Error( + errors?.map((e) => e.message).join('\n') ?? 'unknown', + ) + return Promise.reject(error); + } +} \ No newline at end of file diff --git a/src/codegen/generators/typescript/channels/protocols/http/openapi.yaml b/src/codegen/generators/typescript/channels/protocols/http/openapi.yaml new file mode 100644 index 00000000..a8f9809a --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/openapi.yaml @@ -0,0 +1,741 @@ +openapi: 3.0.0 +servers: + - url: 'http://petstore.swagger.io/v2' +info: + description: >- + This is a sample server Petstore server. For this sample, you can use the api key + `special-key` to test the authorization filters. + version: 1.0.0 + title: OpenAPI Petstore + license: + name: Apache-2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user +paths: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: '' + operationId: addPet + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + put: + tags: + - pet + summary: Update an existing pet + description: '' + operationId: updatePet + externalDocs: + url: "http://petstore.swagger.io/v2/doc/updatePet" + description: "API documentation for the updatePet operation" + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + style: form + explode: false + deprecated: true + schema: + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - 'read:pets' + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + style: form + explode: false + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid tag value + security: + - petstore_auth: + - 'read:pets' + deprecated: true + '/pet/{petId}': + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + description: Updated name of the pet + type: string + status: + description: Updated status of the pet + type: string + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid pet value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}/uploadImage': + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + file: + description: file to upload + type: string + format: binary + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: '' + operationId: placeOrder + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid Order + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: order placed for purchasing the pet + required: true + '/store/order/{orderId}': + get: + tags: + - store + summary: Find purchase order by ID + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + format: int64 + minimum: 1 + maximum: 5 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Created user object + required: true + /user/createWithArray: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithArrayInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithListInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + - name: password + in: query + description: The password for login in clear text + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: + Set-Cookie: + description: >- + Cookie authentication key for use with the `api_key` + apiKey authentication. + schema: + type: string + example: AUTH_KEY=abcde12345; Path=/; HttpOnly + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + responses: + default: + description: successful operation + security: + - api_key: [] + '/user/{username}': + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing. + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Updated user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid user supplied + '404': + description: User not found + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Updated user object + required: true + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found + security: + - api_key: [] +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' +components: + requestBodies: + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: List of user object + required: true + Pet: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + description: Pet object that needs to be added to the store + required: true + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header + schemas: + Order: + title: Pet Order + description: An order for a pets from the pet store + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + default: false + xml: + name: Order + Category: + title: Pet category + description: A category for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + xml: + name: Category + User: + title: a User + description: A User who is purchasing from the pet store + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User + Tag: + title: Pet Tag + description: A tag for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Pet: + title: a Pet + description: A pet for sale in the pet store + type: object + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + category: + $ref: '#/components/schemas/Category' + name: + type: string + example: doggie + photoUrls: + type: array + xml: + name: photoUrl + wrapped: true + items: + type: string + tags: + type: array + xml: + name: tag + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + deprecated: true + enum: + - available + - pending + - sold + xml: + name: Pet + ApiResponse: + title: An uploaded response + description: Describes the result of uploading an image resource + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string From 8befc5f59f6a7789ce91d1ead2a45376c417ab4b Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Fri, 10 Jan 2025 13:18:08 +0100 Subject: [PATCH 04/19] update http --- src/codegen/generators/helpers/payloads.ts | 4 +- .../generators/typescript/channels/index.ts | 97 ++++++++++++++++++- .../channels/protocols/http/common.ts | 50 ++++++++++ .../channels/protocols/http/helpers.ts | 71 -------------- .../channels/protocols/http/index.ts | 94 ++++++++++++++++++ .../generators/typescript/channels/types.ts | 7 +- test/configs/asyncapi-request.yaml | 1 - .../http => test/configs}/openapi.yaml | 0 test/runtime/asyncapi.json | 42 +++++++- test/runtime/typescript/codegen.mjs | 2 +- test/runtime/typescript/package-lock.json | 27 ++++++ test/runtime/typescript/package.json | 3 + .../typescript/test/http_fetch.spec.ts | 38 ++++++++ 13 files changed, 356 insertions(+), 80 deletions(-) create mode 100644 src/codegen/generators/typescript/channels/protocols/http/common.ts delete mode 100644 src/codegen/generators/typescript/channels/protocols/http/helpers.ts create mode 100644 src/codegen/generators/typescript/channels/protocols/http/index.ts rename {src/codegen/generators/typescript/channels/protocols/http => test/configs}/openapi.yaml (100%) create mode 100644 test/runtime/typescript/test/http_fetch.spec.ts diff --git a/src/codegen/generators/helpers/payloads.ts b/src/codegen/generators/helpers/payloads.ts index ef610c28..7290b96b 100644 --- a/src/codegen/generators/helpers/payloads.ts +++ b/src/codegen/generators/helpers/payloads.ts @@ -44,6 +44,9 @@ export async function generateAsyncAPIPayloads( schemaObj.oneOf = []; schemaObj['$id'] = pascalCase(`${preId}_Payload`); for (const message of messages) { + if (message.hasPayload()) { + break; + } const schema = AsyncAPIInputProcessor.convertToInternalSchema( message.payload() as any ); @@ -160,7 +163,6 @@ export function findReplyId( channel: ChannelInterface ) { return ( - (reply.json() as any)?.id ?? `${findOperationId(operation, reply.channel() ?? channel)}_reply` ); } diff --git a/src/codegen/generators/typescript/channels/index.ts b/src/codegen/generators/typescript/channels/index.ts index 6ffe87d8..fc8efa9b 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -41,6 +41,7 @@ import { addPayloadsToDependencies, getMessageTypeAndModule } from './utils'; +import { renderHttpClient } from './protocols/http'; export { renderedFunctionType, TypeScriptChannelRenderType, @@ -357,7 +358,7 @@ export async function generateTypeScriptChannels( messageType: value.messageType, replyType: value.replyType, parameterType: parameter?.model?.type - } as renderedFunctionType; + }; }) ); const renderedDependencies = renders @@ -366,7 +367,101 @@ export async function generateTypeScriptChannels( dependencies.push(...(new Set(renderedDependencies) as any)); break; } + case 'http_client': { + const topic = simpleContext.topic; + let natsContext: RenderRegularParameters = { + ...simpleContext, + topic, + messageType: '' + }; + const renders = []; + const operations = channel.operations().all(); + if (operations.length > 0 && !ignoreOperation) { + for (const operation of operations) { + const payloadId = findOperationId(operation, channel); + const payload = payloads.operationModels[payloadId]; + if (payload === undefined) { + throw new Error( + `Could not find payload for ${payloadId} for channel typescript generator ${JSON.stringify(payloads.operationModels, null, 4)}` + ); + } + const {messageModule, messageType} = + getMessageTypeAndModule(payload); + natsContext = { + ...natsContext, + messageType, + messageModule, + subName: findNameFromOperation(operation, channel) + }; + const statusCodes = operation.messages().all().filter((value) => { + const statusCode = Number(value.bindings().get('http')?.json()['statusCode']); + return statusCode < 200 && statusCode > 300; + }).map((value) => { + const statusCode = Number(value.bindings().get('http')?.json()['statusCode']); + return { + code: statusCode, description: value.description() ?? 'Unknown' + }; + }); + const reply = operation.reply(); + if (reply) { + const replyId = findReplyId(operation, reply, channel); + const replyMessageModel = payloads.operationModels[replyId]; + if (!replyMessageModel) { + continue; + } + const { + messageModule: replyMessageModule, + messageType: replyMessageType + } = getMessageTypeAndModule(replyMessageModel); + const shouldRenderRequest = shouldRenderFunctionType( + functionTypeMapping, + ChannelFunctionTypes.HTTP_CLIENT, + operation.action(), + generator.asyncapiReverseOperations + ); + if (shouldRenderRequest) { + const httpMethod = operation.bindings().get('http')?.json()['method'] ?? 'GET'; + renders.push( + renderHttpClient({ + subName: findNameFromOperation(operation, channel), + requestMessageModule: messageModule, + requestMessageType: messageType, + replyMessageModule, + replyMessageType, + requestTopic: topic, + method: httpMethod.toUpperCase(), + statusCodes, + channelParameters: + parameter !== undefined + ? (parameter.model as any) + : undefined + }) + ); + } + } + } + } + protocolCodeFunctions[protocol].push( + ...renders.map((value) => value.code) + ); + externalProtocolFunctionInformation[protocol].push( + ...renders.map((value) => { + return { + functionType: value.functionType, + functionName: value.functionName, + messageType: value.messageType, + replyType: value.replyType, + parameterType: parameter?.model?.type + }; + }) + ); + const renderedDependencies = renders + .map((value) => value.dependencies) + .flat(Infinity); + dependencies.push(...(new Set(renderedDependencies) as any)); + break; + } default: { break; } diff --git a/src/codegen/generators/typescript/channels/protocols/http/common.ts b/src/codegen/generators/typescript/channels/protocols/http/common.ts new file mode 100644 index 00000000..259b58a4 --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/common.ts @@ -0,0 +1,50 @@ +export function renderCommonHttpCode() { + return `type RequestCredentials = "omit" | "include" | "same-origin"; +export type Json = any; +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +export type HTTPHeaders = { [key: string]: string }; +export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; +export type HTTPBody = Json | URLSearchParams; +export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody }; + +export interface FetchCallbackResponse { + ok: boolean, + status: number, + statusText: string, + json: () => Record | Promise>, +} +export type FetchCallback = (url: string, options: HTTPRequestInit) => FetchCallbackResponse | Promise; + +export interface RequestContext { + payload: RequestPayload, + basePath?: string; // override base path + server?: 'http://localhost:3000' + //username?: string; // parameter for basic security + //password?: string; // parameter for basic security + //apiKey?: string | ((name: string) => string | Promise); // parameter for apiKey security + accessToken?: string | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security + credentials?: RequestCredentials; //value for the credentials param we want to use on each request + additionalHeaders?: HTTPHeaders; //header params we want to use on every request, + fetch?: FetchCallback +} +export interface InternalRequestContext { + payload: RequestPayload, + basePath: string; // override base path + server?: 'http://localhost:3000' + //username?: string; // parameter for basic security + //password?: string; // parameter for basic security + //apiKey?: string | ((name: string) => string | Promise); // parameter for apiKey security + accessToken?: string | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security + credentials?: RequestCredentials; //value for the credentials param we want to use on each request + additionalHeaders?: HTTPHeaders; //header params we want to use on every request, + fetch: FetchCallback +} +export class FetchError extends Error { + override name = "FetchError"; + code: number; + constructor(public cause: Error, code: number, msg: string) { + super(msg); + this.code = code; + } +}`; +} diff --git a/src/codegen/generators/typescript/channels/protocols/http/helpers.ts b/src/codegen/generators/typescript/channels/protocols/http/helpers.ts deleted file mode 100644 index 7fc29b9f..00000000 --- a/src/codegen/generators/typescript/channels/protocols/http/helpers.ts +++ /dev/null @@ -1,71 +0,0 @@ - -export type Json = any; -export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; -export type HTTPHeaders = { [key: string]: string }; -export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; -export type HTTPBody = Json | FormData | URLSearchParams; -export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody }; -type stringPromise = string | Promise; - -interface RequestContext { - requestParams: RequestParams, - postPayload: RequestPayload, - basePath?: string | 'http://localhost:3000'; // override base path - username?: string; // parameter for basic security - password?: string; // parameter for basic security - apiKey?: stringPromise | ((name: string) => stringPromise); // parameter for apiKey security - accessToken?: stringPromise | ((name?: string, scopes?: string[]) => stringPromise); // parameter for oauth2 security - credentials?: RequestCredentials; //value for the credentials param we want to use on each request - additionalHeaders?: HTTPHeaders; //header params we want to use on every request -} -type StatusCodes = 400 | 500; -export class FetchError extends Error { - override name: "FetchError" = "FetchError"; - code: StatusCodes - constructor(public cause: Error, code: StatusCodes) { - let msg: string = 'unknown'; - if(code === 400) { - msg = 'Pet not found' - } - super(msg); - this.code = code - } -} - -async function addPet(context: RequestContext): Promise { - const headers = Object.assign({ - 'Content-Type': 'application/json' - }, context.additionalHeaders); - let url = context.basePath + '/pet'; - - let body: any; - if(context.postPayload) { - body = context.postPayload.marshal(); - } - if (context.accessToken) { - // oauth required - headers["Authorization"] = typeof context.accessToken === 'string' ? context.accessToken : await context.accessToken("petstore_auth", ["write:pets", "read:pets"]); - } - - const response = await fetch('/pet', { - method: 'POST', - headers: headers, - body: '{}', - credentials: context.credentials, - }) - if(response.status === 401) { - return Promise.reject(new FetchError(response, response.status)) - } else if { - - } - const { data, errors } = await response.json() - if (response.ok) { - return ReplyMessage.marshal(data) - } else { - // handle the graphql errors - const error = new Error( - errors?.map((e) => e.message).join('\n') ?? 'unknown', - ) - return Promise.reject(error); - } -} \ No newline at end of file diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts new file mode 100644 index 00000000..f825dd9a --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -0,0 +1,94 @@ +import { ConstrainedObjectModel } from "@asyncapi/modelina"; +import { SingleFunctionRenderType } from "../../../../../types"; +import { pascalCase } from "../../../utils"; +import { ChannelFunctionTypes } from "../../types"; + +export interface RenderHttpParameters { + requestTopic: string; + requestMessageType: string; + requestMessageModule: string | undefined; + replyMessageType: string; + replyMessageModule: string | undefined; + channelParameters: ConstrainedObjectModel | undefined; + statusCodes?: {code: number, description:string}[] + subName?: string; + functionName?: string; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +} + +export function renderHttpClient({ + requestTopic, + requestMessageType, + requestMessageModule, + replyMessageType, + replyMessageModule, + channelParameters, + method, + statusCodes = [], + subName = pascalCase(requestTopic), + functionName = `${method.toLowerCase()}${subName}`, +}: RenderHttpParameters): SingleFunctionRenderType { + const addressToUse = channelParameters + ? `parameters.getChannelWithParameters('${requestTopic}')` + : `'${requestTopic}'`; + const messageType = requestMessageModule + ? `${requestMessageModule}.${requestMessageType}` + : requestMessageType; + const replyType = replyMessageModule + ? `${replyMessageModule}.${replyMessageType}` + : replyMessageType; + const statusCodeChecks = statusCodes.map((value) => { + return `else if (response.status === ${value.code}) { + return Promise.reject(new FetchError(new Error(response.statusText), response.status, '${value.description}')); +}`; + }); + const code = `async ${functionName}(context: RequestContext<${messageType}>): Promise<${replyType}> { + const parsedContext: InternalRequestContext<${messageType}> = { + ...{ + fetch: async (url, options) => { + return NodeFetch.default(url, { + body: options.body, + method: options.method, + headers: options.headers + }) + }, + basePath: '${addressToUse}', + }, + ...context, + } + const headers: HTTPHeaders = { + 'Content-Type': 'application/json', + ...parsedContext.additionalHeaders + }; + const url = parsedContext.server ?? parsedContext.basePath; + + let body: any; + if (parsedContext.payload) { + body = parsedContext.payload.marshal(); + } + if (parsedContext.accessToken) { + // oauth required + headers["Authorization"] = typeof parsedContext.accessToken === 'string' ? parsedContext.accessToken : await parsedContext.accessToken("petstore_auth", ["write:pets", "read:pets"]); + } + + const response = await parsedContext.fetch(url, { + method: ${method}, + headers, + body, + credentials: parsedContext.credentials, + }); + if (response.ok) { + const data = await response.json(); + return ${replyType}.unmarshal(data); + } ${statusCodeChecks.join('\n ')} + return Promise.reject(new Error(response.statusText)); +}`; + return { + messageType, + replyType, + code, + functionName, + dependencies: [`import { URLSearchParams } from 'url';`, `import * as NodeFetch from 'node-fetch'; `], + functionType: ChannelFunctionTypes.HTTP_CLIENT + }; +} diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index 883af458..2ebe5a08 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -12,7 +12,8 @@ export enum ChannelFunctionTypes { NATS_SUBSCRIBE = 'nats_subscribe', NATS_PUBLISH = 'nats_publish', NATS_REQUEST = 'nats_request', - NATS_REPLY = 'nats_reply' + NATS_REPLY = 'nats_reply', + HTTP_CLIENT = 'http_client' } export const zodTypescriptChannelsGenerator = z.object({ @@ -23,7 +24,7 @@ export const zodTypescriptChannelsGenerator = z.object({ .default(['parameters-typescript', 'payloads-typescript']), preset: z.literal('channels').default('channels'), outputPath: z.string().default('src/__gen__/channels'), - protocols: z.array(z.enum(['nats'])).default(['nats']), + protocols: z.array(z.enum(['nats', 'http_client'])).default(['nats', 'http_client']), parameterGeneratorId: z .string() .optional() @@ -115,4 +116,4 @@ export interface RenderRequestReplyParameters { functionName?: string; } -export type SupportedProtocols = 'nats'; +export type SupportedProtocols = 'nats' | 'http_client'; diff --git a/test/configs/asyncapi-request.yaml b/test/configs/asyncapi-request.yaml index a1449894..1ed853d3 100644 --- a/test/configs/asyncapi-request.yaml +++ b/test/configs/asyncapi-request.yaml @@ -19,7 +19,6 @@ operations: messages: - $ref: '#/channels/ping/messages/ping' reply: - id: pingRequestId channel: $ref: '#/channels/ping' messages: diff --git a/src/codegen/generators/typescript/channels/protocols/http/openapi.yaml b/test/configs/openapi.yaml similarity index 100% rename from src/codegen/generators/typescript/channels/protocols/http/openapi.yaml rename to test/configs/openapi.yaml diff --git a/test/runtime/asyncapi.json b/test/runtime/asyncapi.json index c5b53a98..e5645949 100644 --- a/test/runtime/asyncapi.json +++ b/test/runtime/asyncapi.json @@ -40,6 +40,20 @@ "$ref": "#/components/messages/pong" } } + }, + "pingHttp": { + "address": "/ping", + "messages": { + "ping": { + "$ref": "#/components/messages/ping" + }, + "pong": { + "$ref": "#/components/messages/pong" + }, + "pong404": { + "$ref": "#/components/messages/pong404" + } + } } }, "operations": { @@ -74,7 +88,6 @@ {"$ref": "#/channels/ping/messages/ping"} ], "reply": { - "id": "pingRequestId", "channel": { "$ref": "#/channels/ping" }, @@ -83,6 +96,24 @@ ] } }, + "pingRequestHttp": { + "action": "send", + "channel": { + "$ref": "#/channels/pingHttp" + }, + "messages": [ + {"$ref": "#/channels/pingHttp/messages/ping"} + ], + "reply": { + "channel": { + "$ref": "#/channels/pingHttp" + }, + "messages": [ + {"$ref": "#/channels/pingHttp/messages/pong"}, + {"$ref": "#/channels/pingHttp/messages/pong404"} + ] + } + }, "pongReply": { "action": "receive", "channel": { @@ -92,7 +123,6 @@ {"$ref": "#/channels/ping/messages/pong"} ], "reply": { - "id": "pingReplyId", "channel": { "$ref": "#/channels/ping" }, @@ -133,6 +163,14 @@ } } } + }, + "pong404": { + "description": "400 error", + "bindings": { + "http": { + "statusCode": 400 + } + } } }, "schemas": { diff --git a/test/runtime/typescript/codegen.mjs b/test/runtime/typescript/codegen.mjs index 000daf79..8f5c3235 100644 --- a/test/runtime/typescript/codegen.mjs +++ b/test/runtime/typescript/codegen.mjs @@ -20,7 +20,7 @@ export default { { preset: 'channels', outputPath: './src/channels', - protocols: ['nats'] + protocols: ['nats', 'http_client'] }, { preset: 'client', diff --git a/test/runtime/typescript/package-lock.json b/test/runtime/typescript/package-lock.json index 9a7f85b0..aed204a3 100644 --- a/test/runtime/typescript/package-lock.json +++ b/test/runtime/typescript/package-lock.json @@ -10,6 +10,9 @@ "jest": "^27.2.5", "nats": "^2.26.0", "ts-jest": "^27.0.5" + }, + "devDependencies": { + "@types/node-fetch": "^2.6.12" } }, "node_modules/@ampproject/remapping": { @@ -1188,6 +1191,30 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==" }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json index 3b197490..72c6f792 100644 --- a/test/runtime/typescript/package.json +++ b/test/runtime/typescript/package.json @@ -9,5 +9,8 @@ "jest": "^27.2.5", "nats": "^2.26.0", "ts-jest": "^27.0.5" + }, + "devDependencies": { + "@types/node-fetch": "^2.6.12" } } diff --git a/test/runtime/typescript/test/http_fetch.spec.ts b/test/runtime/typescript/test/http_fetch.spec.ts new file mode 100644 index 00000000..4017a1c6 --- /dev/null +++ b/test/runtime/typescript/test/http_fetch.spec.ts @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +import { Protocols } from '../src/channels/index'; +import { Ping } from "../src/payloads/Ping"; +import { Pong } from "../src/payloads/Pong"; +const { http_client } = Protocols; +const { } = http_client; +import http from 'http' + +describe('http_fetch', () => { + describe('channels', () => { + it('should be able to make POST request', async () => { + return new Promise(async (resolve, reject) => { + const requestMessage = new Ping({}) + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}) + const httpServer = http.createServer((req, res) => { + let body = '' + req.on('data', function(data) { + body += data + }) + req.on('end', function() { + try { + expect(req.method).toEqual('POST'); + expect(Ping.unmarshal(body).marshal()).toEqual(requestMessage.marshal()) + } catch(e) { + reject(e); + } + res.write(replyMessage.marshal()); + res.end(); + }) + }).listen(8080); + const receivedReplyMessage = await postPing({payload: requestMessage, basePath: 'http://localhost:8080'}) + expect(receivedReplyMessage.marshal()).toEqual(replyMessage.marshal()) + httpServer.close(); + resolve(); + }); + }); + }); +}); From f92ad77113b92300d4fb87a8cce729d7ae747d46 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Mon, 3 Feb 2025 15:15:15 +0100 Subject: [PATCH 05/19] temp --- .../generators/typescript/channels/index.ts | 18 +- .../__snapshots__/channels.spec.ts.snap | 250 ++++-------------- .../__snapshots__/payload.spec.ts.snap | 22 +- .../generators/typescript/channels.spec.ts | 6 +- test/configs/asyncapi-request.yaml | 8 +- test/runtime/asyncapi.json | 44 +-- 6 files changed, 84 insertions(+), 264 deletions(-) diff --git a/src/codegen/generators/typescript/channels/index.ts b/src/codegen/generators/typescript/channels/index.ts index 96093f5a..cb41054d 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -559,7 +559,7 @@ export async function generateTypeScriptChannels( const topic = simpleContext.topic; const renders = []; const operations = channel.operations().all(); - if (operations.length > 0 && !ignoreOperation) { + if (operations.length > 0) { for (const operation of operations) { const payloadId = findOperationId(operation, channel); const payload = payloads.operationModels[payloadId]; @@ -570,15 +570,6 @@ export async function generateTypeScriptChannels( } const {messageModule, messageType} = getMessageTypeAndModule(payload); - const statusCodes = operation.messages().all().filter((value) => { - const statusCode = Number(value.bindings().get('http')?.json()['statusCode']); - return statusCode < 200 && statusCode > 300; - }).map((value) => { - const statusCode = Number(value.bindings().get('http')?.json()['statusCode']); - return { - code: statusCode, description: value.description() ?? 'Unknown' - }; - }); const reply = operation.reply(); if (reply) { const replyId = findReplyId(operation, reply, channel); @@ -586,6 +577,13 @@ export async function generateTypeScriptChannels( if (!replyMessageModel) { continue; } + const statusCodes = operation.reply()?.messages().all().map((value) => { + const statusCode = Number(value.bindings().get('http')?.json()['statusCode']); + value.id(); + return { + code: statusCode, description: value.description() ?? 'Unknown', messageModule, messageType + }; + }); const { messageModule: replyMessageModule, messageType: replyMessageType diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index fa459607..dbc60332 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -4,9 +4,9 @@ exports[`channels typescript should work with basic AsyncAPI inputs 1`] = ` "import * as TestPayloadModelModule from './../../../../TestPayloadModel'; import {TestParameter} from './../../../../TestParameter'; import * as Nats from 'nats'; +import * as Amqp from 'amqplib'; import * as Kafka from 'kafkajs'; import * as Mqtt from 'mqtt'; -import * as Amqp from 'amqplib'; export const Protocols = { nats: { /** @@ -165,6 +165,56 @@ await js.publish('user.signedup', dataToSend, options); }); } }, +amqp: { + /** + * AMQP publish operation for exchange \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + + */ +publishToUserSignedupExchange: ( + message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: {exchange: string | undefined} & Amqp.Options.Publish +): Promise => { + return new Promise(async (resolve, reject) => { + const exchange = options?.exchange ?? 'undefined'; + if(!exchange) { + return reject('No exchange value found, please provide one') + } + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const routingKey = 'user/signedup'; +channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * AMQP publish operation for queue \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + + */ +publishToUserSignedupQueue: ( + message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: Amqp.Options.Publish +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const queue = 'user/signedup'; +channel.sendToQueue(queue, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, kafka: { /** * Kafka publish operation for \`user.signedup\` @@ -255,56 +305,6 @@ mqtt.publish('user/signedup', dataToSend); } }); } -}, -amqp: { - /** - * AMQP publish operation for exchange \`user/signedup\` - * - * @param message to publish - * @param amqp the AMQP connection to send over - - */ -publishToUserSignedupExchange: ( - message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: {exchange: string | undefined} & Amqp.Options.Publish -): Promise => { - return new Promise(async (resolve, reject) => { - const exchange = options?.exchange ?? 'undefined'; - if(!exchange) { - return reject('No exchange value found, please provide one') - } - try { - let dataToSend: any = MessageTypeModule.marshal(message); -const channel = await amqp.createChannel(); -const routingKey = 'user/signedup'; -channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); - resolve(); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * AMQP publish operation for queue \`user/signedup\` - * - * @param message to publish - * @param amqp the AMQP connection to send over - - */ -publishToUserSignedupQueue: ( - message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: Amqp.Options.Publish -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); -const channel = await amqp.createChannel(); -const queue = 'user/signedup'; -channel.sendToQueue(queue, Buffer.from(dataToSend), options); - resolve(); - } catch (e: any) { - reject(e); - } - }); -} }};" `; @@ -612,15 +612,15 @@ channel.sendToQueue(queue, Buffer.from(dataToSend), options); } }); } +}, +http_client: { + }};" `; exports[`channels typescript should work with request and reply AsyncAPI 1`] = ` "import * as TestPayloadModelModule from './../../../../TestPayloadModel'; import * as Nats from 'nats'; -import * as Kafka from 'kafkajs'; -import * as Mqtt from 'mqtt'; -import * as Amqp from 'amqplib'; export const Protocols = { nats: { /** @@ -779,145 +779,7 @@ await js.publish('ping', dataToSend, options); }); } }, -kafka: { - /** - * Kafka publish operation for \`ping\` - * - * @param message to publish - * @param kafka the KafkaJS client to publish from - */ -produceToPing: ( - message: MessageTypeModule.MessageType, kafka: Kafka.Kafka -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); - const producer = kafka.producer(); - await producer.connect(); - await producer.send({ - topic: 'ping', - messages: [ - { value: dataToSend }, - ], - }); - resolve(producer); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * Callback for when receiving messages - * - * @callback consumeFromPingCallback - * @param err if any error occurred this will be sat - * @param msg that was received - * @param kafkaMsg - */ - -/** - * Kafka subscription for \`ping\` - * - * @param {consumeFromPingCallback} onDataCallback to call when messages are received - * @param kafka the KafkaJS client to subscribe through - * @param options when setting up the subscription - */ -consumeFromPing: ( - onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, kafkaMsg?: Kafka.EachMessagePayload) => void, kafka: Kafka.Kafka, options: {fromBeginning: boolean, groupId: string} = {fromBeginning: true, groupId: ''} -): Promise => { - return new Promise(async (resolve, reject) => { - try { - if(!options.groupId) { - reject('No group ID provided'); - } - const consumer = kafka.consumer({ groupId: options.groupId }); - - await consumer.connect(); - await consumer.subscribe({ topic: 'ping', fromBeginning: options.fromBeginning }); - await consumer.run({ - eachMessage: async (kafkaMessage: Kafka.EachMessagePayload) => { - const { topic, message } = kafkaMessage; - - const callbackData = MessageTypeModule.unmarshal(message.value?.toString()!); - onDataCallback(undefined, callbackData, kafkaMessage); - } - }); - resolve(consumer); - } catch (e: any) { - reject(e); - } - }); -} -}, -mqtt: { - /** - * MQTT publish operation for \`/ping\` - * - * @param message to publish - * @param mqtt the MQTT client to publish from - */ -publishToPing: ( - message: MessageTypeModule.MessageType, mqtt: Mqtt.MqttClient -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); -mqtt.publish('/ping', dataToSend); - resolve(); - } catch (e: any) { - reject(e); - } - }); -} -}, -amqp: { - /** - * AMQP publish operation for exchange \`/ping\` - * - * @param message to publish - * @param amqp the AMQP connection to send over - - */ -publishToPingExchange: ( - message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: {exchange: string | undefined} & Amqp.Options.Publish -): Promise => { - return new Promise(async (resolve, reject) => { - const exchange = options?.exchange ?? 'undefined'; - if(!exchange) { - return reject('No exchange value found, please provide one') - } - try { - let dataToSend: any = MessageTypeModule.marshal(message); -const channel = await amqp.createChannel(); -const routingKey = '/ping'; -channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); - resolve(); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * AMQP publish operation for queue \`/ping\` - * - * @param message to publish - * @param amqp the AMQP connection to send over - - */ -publishToPingQueue: ( - message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: Amqp.Options.Publish -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); -const channel = await amqp.createChannel(); -const queue = '/ping'; -channel.sendToQueue(queue, Buffer.from(dataToSend), options); - resolve(); - } catch (e: any) { - reject(e); - } - }); -} +http_client: { + }};" `; diff --git a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap index ea840900..456bab2c 100644 --- a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap @@ -1,26 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`payloads typescript should work with basic AsyncAPI inputs 1`] = ` -"import {SimpleObject} from './SimpleObject'; -type UnionPayload = SimpleObject | boolean | string; - - -export function unmarshal(json: any): UnionPayload { - if(typeof json === 'object') { - if(json.type === 'SimpleObject') { - return SimpleObject.unmarshal(json); - } - } - return JSON.parse(json); -} -export function marshal(payload: UnionPayload) { - if(payload instanceof SimpleObject) { - return payload.marshal(); -} - - - return JSON.stringify(payload); -} +" +type UnionPayload = Record; export { UnionPayload };" `; diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index c863e3d1..dc449475 100644 --- a/test/codegen/generators/typescript/channels.spec.ts +++ b/test/codegen/generators/typescript/channels.spec.ts @@ -39,6 +39,7 @@ describe('channels', () => { outputPath: path.resolve(__dirname, './output'), id: 'test', asyncapiGenerateForOperations: false, + protocols: ['nats', 'amqp', 'kafka', 'mqtt'] }, inputType: 'asyncapi', asyncapiDocument: parsedAsyncAPIDocument, @@ -64,7 +65,7 @@ describe('channels', () => { } }, operationModels: { - pingRequestId: { + pingRequest: { messageModel: payloadModel, messageType: 'MessageType' } @@ -77,7 +78,8 @@ describe('channels', () => { ...defaultTypeScriptChannelsGenerator, outputPath: path.resolve(__dirname, './output'), id: 'test', - asyncapiGenerateForOperations: false + asyncapiGenerateForOperations: false, + protocols: ['nats', 'http_client'] }, inputType: 'asyncapi', asyncapiDocument: parsedAsyncAPIDocument, diff --git a/test/configs/asyncapi-request.yaml b/test/configs/asyncapi-request.yaml index 1ed853d3..10ef14a2 100644 --- a/test/configs/asyncapi-request.yaml +++ b/test/configs/asyncapi-request.yaml @@ -23,6 +23,9 @@ operations: $ref: '#/channels/ping' messages: - $ref: '#/channels/ping/messages/pong' + bindings: + http: + method: GET components: messages: ping: @@ -38,4 +41,7 @@ components: properties: event: type: string - const: pong \ No newline at end of file + const: pong + bindings: + http: + statusCode: 200 \ No newline at end of file diff --git a/test/runtime/asyncapi.json b/test/runtime/asyncapi.json index f0781fc2..4d3a72b7 100644 --- a/test/runtime/asyncapi.json +++ b/test/runtime/asyncapi.json @@ -40,20 +40,6 @@ "$ref": "#/components/messages/pong" } } - }, - "pingHttp": { - "address": "/ping", - "messages": { - "ping": { - "$ref": "#/components/messages/ping" - }, - "pong": { - "$ref": "#/components/messages/pong" - }, - "pong404": { - "$ref": "#/components/messages/pong404" - } - } } }, "operations": { @@ -87,6 +73,11 @@ "messages": [ {"$ref": "#/channels/ping/messages/ping"} ], + "bindings": { + "http": { + "method": "GET" + } + }, "reply": { "channel": { "$ref": "#/channels/ping" @@ -96,24 +87,6 @@ ] } }, - "pingRequestHttp": { - "action": "send", - "channel": { - "$ref": "#/channels/pingHttp" - }, - "messages": [ - {"$ref": "#/channels/pingHttp/messages/ping"} - ], - "reply": { - "channel": { - "$ref": "#/channels/pingHttp" - }, - "messages": [ - {"$ref": "#/channels/pingHttp/messages/pong"}, - {"$ref": "#/channels/pingHttp/messages/pong404"} - ] - } - }, "pongReply": { "action": "receive", "channel": { @@ -162,13 +135,10 @@ "description": "pong name" } } - } - }, - "pong404": { - "description": "400 error", + }, "bindings": { "http": { - "statusCode": 400 + "statusCode": "200" } } } From a81ba8eb58849db4f84513b358017be31fd61610 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 13 Feb 2025 21:39:40 +0100 Subject: [PATCH 06/19] update snapshot --- docs/protocols/http.md | 4 +- .../generators/typescript/channels/types.ts | 2 +- .../__snapshots__/channels.spec.ts.snap | 1172 +++++++++++++++++ .../generators/typescript/channels.spec.ts | 2 +- 4 files changed, 1175 insertions(+), 5 deletions(-) diff --git a/docs/protocols/http.md b/docs/protocols/http.md index 51910898..591f9886 100644 --- a/docs/protocols/http.md +++ b/docs/protocols/http.md @@ -8,12 +8,10 @@ Both client and server generator is available. It is currently available through the generators ([channels](../generators/channels.md)): -All of this is available through [AsyncAPI](../inputs/asyncapi.md). +All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP method binding for operation. ## TypeScript -## Server features - | **Feature** | Client | Server | |---|---|---| | Download | ➗ | ➗ | diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index 21e06408..4acc5918 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -18,7 +18,7 @@ export enum ChannelFunctionTypes { KAFKA_SUBSCRIBE = 'kafka_subscribe', AMQP_QUEUE_PUBLISH = 'amqp_queue_publish', AMQP_EXCHANGE_PUBLISH = 'amqp_exchange_publish', - HTTP_CLIENT = 'http_client' + HTTP_CLIENT = 'http_client', EVENT_SOURCE_FETCH = 'event_source_fetch', EVENT_SOURCE_EXPRESS = 'event_source_express' } diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index e69de29b..3477d50b 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -0,0 +1,1172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`channels typescript should work with basic AsyncAPI inputs 1`] = ` +"import * as TestPayloadModelModule from './../../../../TestPayloadModel'; +import {TestParameter} from './../../../../TestParameter'; +import * as Nats from 'nats'; +import * as Amqp from 'amqplib'; +import * as Mqtt from 'mqtt'; +import * as Kafka from 'kafkajs'; +import { fetchEventSource, EventStreamContentType, EventSourceMessage } from '@microsoft/fetch-event-source'; +import { NextFunction, Request, Response, Router } from 'express'; +export const Protocols = { +nats: { + /** + * NATS publish operation for \`user.signedup\` + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +publishToUserSignedup: ( + message: MessageTypeModule.MessageType, nc: Nats.NatsConnection, codec: any = Nats.JSONCodec(), options?: Nats.PublishOptions +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +nc.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback subscribeToUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Core subscription for \`user.signedup\` + * + * @param {subscribeToUserSignedupCallback} onDataCallback to call when messages are received + * @param nc the NATS client to subscribe through + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + */ +subscribeToUserSignedup: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, natsMsg?: Nats.Msg) => void, nc: Nats.NatsConnection, codec: any = Nats.JSONCodec(), options?: Nats.SubscriptionOptions +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for \`user.signedup\` + * + * @param {jetStreamPullSubscribeToUserSignedupCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while receiving the message + */ +jetStreamPullSubscribeToUserSignedup: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, js: Nats.JetStreamClient, options: Nats.ConsumerOptsBuilder | Partial, codec: any = Nats.JSONCodec() +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for \`user.signedup\` + * + * @param {jetStreamPushSubscriptionFromUserSignedupCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while receiving the message + */ +jetStreamPushSubscriptionFromUserSignedup: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, js: Nats.JetStreamClient, options: Nats.ConsumerOptsBuilder | Partial, codec: any = Nats.JSONCodec() +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * JetStream publish operation for \`user.signedup\` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +jetStreamPublishToUserSignedup: ( + message: MessageTypeModule.MessageType, js: Nats.JetStreamClient, codec: any = Nats.JSONCodec(), options: Partial = {} +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +await js.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +amqp: { + /** + * AMQP publish operation for exchange \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + + */ +publishToUserSignedupExchange: ( + message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: {exchange: string | undefined} & Amqp.Options.Publish +): Promise => { + return new Promise(async (resolve, reject) => { + const exchange = options?.exchange ?? 'undefined'; + if(!exchange) { + return reject('No exchange value found, please provide one') + } + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const routingKey = 'user/signedup'; +channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * AMQP publish operation for queue \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + + */ +publishToUserSignedupQueue: ( + message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: Amqp.Options.Publish +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const queue = 'user/signedup'; +channel.sendToQueue(queue, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +mqtt: { + /** + * MQTT publish operation for \`user/signedup\` + * + * @param message to publish + * @param mqtt the MQTT client to publish from + */ +publishToUserSignedup: ( + message: MessageTypeModule.MessageType, mqtt: Mqtt.MqttClient +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +mqtt.publish('user/signedup', dataToSend); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +kafka: { + /** + * Kafka publish operation for \`user.signedup\` + * + * @param message to publish + * @param kafka the KafkaJS client to publish from + */ +produceToUserSignedup: ( + message: MessageTypeModule.MessageType, kafka: Kafka.Kafka +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); + const producer = kafka.producer(); + await producer.connect(); + await producer.send({ + topic: 'user.signedup', + messages: [ + { value: dataToSend }, + ], + }); + resolve(producer); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback consumeFromUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param kafkaMsg + */ + +/** + * Kafka subscription for \`user.signedup\` + * + * @param {consumeFromUserSignedupCallback} onDataCallback to call when messages are received + * @param kafka the KafkaJS client to subscribe through + * @param options when setting up the subscription + */ +consumeFromUserSignedup: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, kafkaMsg?: Kafka.EachMessagePayload) => void, kafka: Kafka.Kafka, options: {fromBeginning: boolean, groupId: string} = {fromBeginning: true, groupId: ''} +): Promise => { + return new Promise(async (resolve, reject) => { + try { + if(!options.groupId) { + reject('No group ID provided'); + } + const consumer = kafka.consumer({ groupId: options.groupId }); + + await consumer.connect(); + await consumer.subscribe({ topic: 'user.signedup', fromBeginning: options.fromBeginning }); + await consumer.run({ + eachMessage: async (kafkaMessage: Kafka.EachMessagePayload) => { + const { topic, message } = kafkaMessage; + + const callbackData = MessageTypeModule.unmarshal(message.value?.toString()!); + onDataCallback(undefined, callbackData, kafkaMessage); + } + }); + resolve(consumer); + } catch (e: any) { + reject(e); + } + }); +} +}, +event_source: { + /** + * Event source fetch for \`user/signedup\` + * + * @param callback to call when receiving events + * @param options additionally used to handle the event source + */ +listenForUserSignedup: async ( + callback: (messageEvent: MessageTypeModule.MessageType | null, error?: string) => void, + options: {authorization?: string, onClose?: (err?: string) => void, baseUrl: string} +) => { + let eventsUrl: string = 'user/signedup'; + const url = \`\${options.baseUrl}/\${eventsUrl}\` + const headers: Record = { + Accept: 'text/event-stream', + } + if(options.authorization) { + headers['authorization'] = \`Bearer \${options?.authorization}\`; + } + await fetchEventSource(\`\${url}\`, { + method: 'GET', + headers, + onmessage: (ev: EventSourceMessage) => { + const callbackData = MessageTypeModule.unmarshal(ev.data); + callback(callbackData, undefined); + }, + onerror: (err) => { + options.onClose?.(err); + }, + onclose: () => { + options.onClose?.(); + }, + async onopen(response: { ok: any; headers: { get: (arg0: string) => any }; status: number }) { + if (response.ok && response.headers.get('content-type') === EventStreamContentType) { + return // everything's good + } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { + // client-side errors are usually non-retriable: + callback(null, 'Client side error, could not open event connection') + } else { + callback(null, 'Unknown error, could not open event connection'); + } + }, + }) +} +, +registerUserSignedup: ( + router: Router, + callback: ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => void) | ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => Promise) +) => { + const event = '/user/signedup'; + router.get(event, async (req, res, next) => { + + res.writeHead(200, { + 'Cache-Control': 'no-cache, no-transform', + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }) + const sendEventCallback = (message: MessageTypeModule.MessageType) => { + if (res.closed) { + return + } + res.write(\`event: \${event}\\n\`) + res.write(\`data: \${MessageTypeModule.marshal(message)}\\n\\n\`) + } + await callback(req, res, next, sendEventCallback) + }) +} + +}};" +`; + +exports[`channels typescript should work with basic AsyncAPI inputs with no parameters 1`] = ` +"import * as TestPayloadModelModule from './../../../../TestPayloadModel'; +import * as Nats from 'nats'; +import * as Amqp from 'amqplib'; +import * as Mqtt from 'mqtt'; +import * as Kafka from 'kafkajs'; +import { fetchEventSource, EventStreamContentType, EventSourceMessage } from '@microsoft/fetch-event-source'; +import { NextFunction, Request, Response, Router } from 'express'; +export const Protocols = { +nats: { + /** + * NATS publish operation for \`user.signedup\` + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +publishToUserSignedup: ( + message: MessageTypeModule.MessageType, nc: Nats.NatsConnection, codec: any = Nats.JSONCodec(), options?: Nats.PublishOptions +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +nc.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback subscribeToUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Core subscription for \`user.signedup\` + * + * @param {subscribeToUserSignedupCallback} onDataCallback to call when messages are received + * @param nc the NATS client to subscribe through + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + */ +subscribeToUserSignedup: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, natsMsg?: Nats.Msg) => void, nc: Nats.NatsConnection, codec: any = Nats.JSONCodec(), options?: Nats.SubscriptionOptions +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for \`user.signedup\` + * + * @param {jetStreamPullSubscribeToUserSignedupCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while receiving the message + */ +jetStreamPullSubscribeToUserSignedup: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, js: Nats.JetStreamClient, options: Nats.ConsumerOptsBuilder | Partial, codec: any = Nats.JSONCodec() +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for \`user.signedup\` + * + * @param {jetStreamPushSubscriptionFromUserSignedupCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while receiving the message + */ +jetStreamPushSubscriptionFromUserSignedup: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, js: Nats.JetStreamClient, options: Nats.ConsumerOptsBuilder | Partial, codec: any = Nats.JSONCodec() +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * JetStream publish operation for \`user.signedup\` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +jetStreamPublishToUserSignedup: ( + message: MessageTypeModule.MessageType, js: Nats.JetStreamClient, codec: any = Nats.JSONCodec(), options: Partial = {} +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +await js.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +amqp: { + /** + * AMQP publish operation for exchange \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + + */ +publishToUserSignedupExchange: ( + message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: {exchange: string | undefined} & Amqp.Options.Publish +): Promise => { + return new Promise(async (resolve, reject) => { + const exchange = options?.exchange ?? 'undefined'; + if(!exchange) { + return reject('No exchange value found, please provide one') + } + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const routingKey = 'user/signedup'; +channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * AMQP publish operation for queue \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + + */ +publishToUserSignedupQueue: ( + message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: Amqp.Options.Publish +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const queue = 'user/signedup'; +channel.sendToQueue(queue, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +mqtt: { + /** + * MQTT publish operation for \`user/signedup\` + * + * @param message to publish + * @param mqtt the MQTT client to publish from + */ +publishToUserSignedup: ( + message: MessageTypeModule.MessageType, mqtt: Mqtt.MqttClient +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +mqtt.publish('user/signedup', dataToSend); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +kafka: { + /** + * Kafka publish operation for \`user.signedup\` + * + * @param message to publish + * @param kafka the KafkaJS client to publish from + */ +produceToUserSignedup: ( + message: MessageTypeModule.MessageType, kafka: Kafka.Kafka +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); + const producer = kafka.producer(); + await producer.connect(); + await producer.send({ + topic: 'user.signedup', + messages: [ + { value: dataToSend }, + ], + }); + resolve(producer); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback consumeFromUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param kafkaMsg + */ + +/** + * Kafka subscription for \`user.signedup\` + * + * @param {consumeFromUserSignedupCallback} onDataCallback to call when messages are received + * @param kafka the KafkaJS client to subscribe through + * @param options when setting up the subscription + */ +consumeFromUserSignedup: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, kafkaMsg?: Kafka.EachMessagePayload) => void, kafka: Kafka.Kafka, options: {fromBeginning: boolean, groupId: string} = {fromBeginning: true, groupId: ''} +): Promise => { + return new Promise(async (resolve, reject) => { + try { + if(!options.groupId) { + reject('No group ID provided'); + } + const consumer = kafka.consumer({ groupId: options.groupId }); + + await consumer.connect(); + await consumer.subscribe({ topic: 'user.signedup', fromBeginning: options.fromBeginning }); + await consumer.run({ + eachMessage: async (kafkaMessage: Kafka.EachMessagePayload) => { + const { topic, message } = kafkaMessage; + + const callbackData = MessageTypeModule.unmarshal(message.value?.toString()!); + onDataCallback(undefined, callbackData, kafkaMessage); + } + }); + resolve(consumer); + } catch (e: any) { + reject(e); + } + }); +} +}, +event_source: { + /** + * Event source fetch for \`user/signedup\` + * + * @param callback to call when receiving events + * @param options additionally used to handle the event source + */ +listenForUserSignedup: async ( + callback: (messageEvent: MessageTypeModule.MessageType | null, error?: string) => void, + options: {authorization?: string, onClose?: (err?: string) => void, baseUrl: string} +) => { + let eventsUrl: string = 'user/signedup'; + const url = \`\${options.baseUrl}/\${eventsUrl}\` + const headers: Record = { + Accept: 'text/event-stream', + } + if(options.authorization) { + headers['authorization'] = \`Bearer \${options?.authorization}\`; + } + await fetchEventSource(\`\${url}\`, { + method: 'GET', + headers, + onmessage: (ev: EventSourceMessage) => { + const callbackData = MessageTypeModule.unmarshal(ev.data); + callback(callbackData, undefined); + }, + onerror: (err) => { + options.onClose?.(err); + }, + onclose: () => { + options.onClose?.(); + }, + async onopen(response: { ok: any; headers: { get: (arg0: string) => any }; status: number }) { + if (response.ok && response.headers.get('content-type') === EventStreamContentType) { + return // everything's good + } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { + // client-side errors are usually non-retriable: + callback(null, 'Client side error, could not open event connection') + } else { + callback(null, 'Unknown error, could not open event connection'); + } + }, + }) +} +, +registerUserSignedup: ( + router: Router, + callback: ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => void) | ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => Promise) +) => { + const event = '/user/signedup'; + router.get(event, async (req, res, next) => { + + res.writeHead(200, { + 'Cache-Control': 'no-cache, no-transform', + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }) + const sendEventCallback = (message: MessageTypeModule.MessageType) => { + if (res.closed) { + return + } + res.write(\`event: \${event}\\n\`) + res.write(\`data: \${MessageTypeModule.marshal(message)}\\n\\n\`) + } + await callback(req, res, next, sendEventCallback) + }) +} + +}};" +`; + +exports[`channels typescript should work with operation extension 1`] = ` +"import * as TestPayloadModelModule from './../../../../TestPayloadModel'; +import * as Nats from 'nats'; +export const Protocols = { +nats: { + /** + * JetStream publish operation for \`user.signedup\` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +jetStreamPublishToUserSignedup: ( + message: MessageTypeModule.MessageType, js: Nats.JetStreamClient, codec: any = Nats.JSONCodec(), options: Partial = {} +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +await js.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}};" +`; + +exports[`channels typescript should work with request and reply AsyncAPI 1`] = ` +"import * as TestPayloadModelModule from './../../../../TestPayloadModel'; +import * as Nats from 'nats'; +import * as Amqp from 'amqplib'; +import * as Mqtt from 'mqtt'; +import * as Kafka from 'kafkajs'; +import { fetchEventSource, EventStreamContentType, EventSourceMessage } from '@microsoft/fetch-event-source'; +import { NextFunction, Request, Response, Router } from 'express'; +export const Protocols = { +nats: { + /** + * NATS publish operation for \`ping\` + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +publishToPing: ( + message: MessageTypeModule.MessageType, nc: Nats.NatsConnection, codec: any = Nats.JSONCodec(), options?: Nats.PublishOptions +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +nc.publish('ping', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback subscribeToPingCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Core subscription for \`ping\` + * + * @param {subscribeToPingCallback} onDataCallback to call when messages are received + * @param nc the NATS client to subscribe through + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + */ +subscribeToPing: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, natsMsg?: Nats.Msg) => void, nc: Nats.NatsConnection, codec: any = Nats.JSONCodec(), options?: Nats.SubscriptionOptions +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('ping', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToPingCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for \`ping\` + * + * @param {jetStreamPullSubscribeToPingCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while receiving the message + */ +jetStreamPullSubscribeToPing: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, js: Nats.JetStreamClient, options: Nats.ConsumerOptsBuilder | Partial, codec: any = Nats.JSONCodec() +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('ping', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromPingCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for \`ping\` + * + * @param {jetStreamPushSubscriptionFromPingCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while receiving the message + */ +jetStreamPushSubscriptionFromPing: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, js: Nats.JetStreamClient, options: Nats.ConsumerOptsBuilder | Partial, codec: any = Nats.JSONCodec() +): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('ping', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * JetStream publish operation for \`ping\` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +jetStreamPublishToPing: ( + message: MessageTypeModule.MessageType, js: Nats.JetStreamClient, codec: any = Nats.JSONCodec(), options: Partial = {} +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +await js.publish('ping', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +amqp: { + /** + * AMQP publish operation for exchange \`/ping\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + + */ +publishToPingExchange: ( + message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: {exchange: string | undefined} & Amqp.Options.Publish +): Promise => { + return new Promise(async (resolve, reject) => { + const exchange = options?.exchange ?? 'undefined'; + if(!exchange) { + return reject('No exchange value found, please provide one') + } + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const routingKey = '/ping'; +channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * AMQP publish operation for queue \`/ping\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + + */ +publishToPingQueue: ( + message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: Amqp.Options.Publish +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const queue = '/ping'; +channel.sendToQueue(queue, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +mqtt: { + /** + * MQTT publish operation for \`/ping\` + * + * @param message to publish + * @param mqtt the MQTT client to publish from + */ +publishToPing: ( + message: MessageTypeModule.MessageType, mqtt: Mqtt.MqttClient +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +mqtt.publish('/ping', dataToSend); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +kafka: { + /** + * Kafka publish operation for \`ping\` + * + * @param message to publish + * @param kafka the KafkaJS client to publish from + */ +produceToPing: ( + message: MessageTypeModule.MessageType, kafka: Kafka.Kafka +): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); + const producer = kafka.producer(); + await producer.connect(); + await producer.send({ + topic: 'ping', + messages: [ + { value: dataToSend }, + ], + }); + resolve(producer); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback consumeFromPingCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param kafkaMsg + */ + +/** + * Kafka subscription for \`ping\` + * + * @param {consumeFromPingCallback} onDataCallback to call when messages are received + * @param kafka the KafkaJS client to subscribe through + * @param options when setting up the subscription + */ +consumeFromPing: ( + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, kafkaMsg?: Kafka.EachMessagePayload) => void, kafka: Kafka.Kafka, options: {fromBeginning: boolean, groupId: string} = {fromBeginning: true, groupId: ''} +): Promise => { + return new Promise(async (resolve, reject) => { + try { + if(!options.groupId) { + reject('No group ID provided'); + } + const consumer = kafka.consumer({ groupId: options.groupId }); + + await consumer.connect(); + await consumer.subscribe({ topic: 'ping', fromBeginning: options.fromBeginning }); + await consumer.run({ + eachMessage: async (kafkaMessage: Kafka.EachMessagePayload) => { + const { topic, message } = kafkaMessage; + + const callbackData = MessageTypeModule.unmarshal(message.value?.toString()!); + onDataCallback(undefined, callbackData, kafkaMessage); + } + }); + resolve(consumer); + } catch (e: any) { + reject(e); + } + }); +} +}, +event_source: { + /** + * Event source fetch for \`/ping\` + * + * @param callback to call when receiving events + * @param options additionally used to handle the event source + */ +listenForPing: async ( + callback: (messageEvent: MessageTypeModule.MessageType | null, error?: string) => void, + options: {authorization?: string, onClose?: (err?: string) => void, baseUrl: string} +) => { + let eventsUrl: string = '/ping'; + const url = \`\${options.baseUrl}/\${eventsUrl}\` + const headers: Record = { + Accept: 'text/event-stream', + } + if(options.authorization) { + headers['authorization'] = \`Bearer \${options?.authorization}\`; + } + await fetchEventSource(\`\${url}\`, { + method: 'GET', + headers, + onmessage: (ev: EventSourceMessage) => { + const callbackData = MessageTypeModule.unmarshal(ev.data); + callback(callbackData, undefined); + }, + onerror: (err) => { + options.onClose?.(err); + }, + onclose: () => { + options.onClose?.(); + }, + async onopen(response: { ok: any; headers: { get: (arg0: string) => any }; status: number }) { + if (response.ok && response.headers.get('content-type') === EventStreamContentType) { + return // everything's good + } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { + // client-side errors are usually non-retriable: + callback(null, 'Client side error, could not open event connection') + } else { + callback(null, 'Unknown error, could not open event connection'); + } + }, + }) +} +, +registerPing: ( + router: Router, + callback: ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => void) | ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => Promise) +) => { + const event = '/ping'; + router.get(event, async (req, res, next) => { + + res.writeHead(200, { + 'Cache-Control': 'no-cache, no-transform', + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }) + const sendEventCallback = (message: MessageTypeModule.MessageType) => { + if (res.closed) { + return + } + res.write(\`event: \${event}\\n\`) + res.write(\`data: \${MessageTypeModule.marshal(message)}\\n\\n\`) + } + await callback(req, res, next, sendEventCallback) + }) +} + +}, +http_client: { + +}};" +`; diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index 1decc41f..6b291506 100644 --- a/test/codegen/generators/typescript/channels.spec.ts +++ b/test/codegen/generators/typescript/channels.spec.ts @@ -39,7 +39,7 @@ describe('channels', () => { outputPath: path.resolve(__dirname, './output'), id: 'test', asyncapiGenerateForOperations: false, - protocols: ['nats', 'amqp', 'mqtt', 'kafka', 'event_source', 'http_client'] + protocols: ['nats', 'amqp', 'mqtt', 'kafka', 'event_source'] }, inputType: 'asyncapi', asyncapiDocument: parsedAsyncAPIDocument, From 0361776c65e8eb34ee394aba53464996f43b93b6 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 13 Feb 2025 23:01:58 +0100 Subject: [PATCH 07/19] fix impl --- docs/protocols/http.md | 2 +- .../typescript/channels/asyncapi.ts | 3 +- .../generators/typescript/channels/index.ts | 22 +- .../channels/protocols/http/common.ts | 5 +- .../channels/protocols/http/index.ts | 6 +- .../__snapshots__/channels.spec.ts.snap | 414 ++---------------- .../generators/typescript/channels.spec.ts | 14 +- test/configs/asyncapi-request.yaml | 14 + test/runtime/typescript/package.json | 4 +- .../typescript/test/http_fetch.spec.ts | 38 -- 10 files changed, 89 insertions(+), 433 deletions(-) delete mode 100644 test/runtime/typescript/test/http_fetch.spec.ts diff --git a/docs/protocols/http.md b/docs/protocols/http.md index 591f9886..6a9e0ab3 100644 --- a/docs/protocols/http.md +++ b/docs/protocols/http.md @@ -8,7 +8,7 @@ Both client and server generator is available. It is currently available through the generators ([channels](../generators/channels.md)): -All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP method binding for operation. +All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP `method` binding for operation and `statusCode` for messages. ## TypeScript diff --git a/src/codegen/generators/typescript/channels/asyncapi.ts b/src/codegen/generators/typescript/channels/asyncapi.ts index 4a0156df..46147048 100644 --- a/src/codegen/generators/typescript/channels/asyncapi.ts +++ b/src/codegen/generators/typescript/channels/asyncapi.ts @@ -9,7 +9,8 @@ const sendingFunctionTypes = [ ChannelFunctionTypes.KAFKA_PUBLISH, ChannelFunctionTypes.AMQP_EXCHANGE_PUBLISH, ChannelFunctionTypes.AMQP_QUEUE_PUBLISH, - ChannelFunctionTypes.EVENT_SOURCE_EXPRESS + ChannelFunctionTypes.EVENT_SOURCE_EXPRESS, + ChannelFunctionTypes.HTTP_CLIENT ]; const receivingFunctionTypes = [ ChannelFunctionTypes.NATS_JETSTREAM_PULL_SUBSCRIBE, diff --git a/src/codegen/generators/typescript/channels/index.ts b/src/codegen/generators/typescript/channels/index.ts index 93341f8e..e2068465 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -573,13 +573,21 @@ export async function generateTypeScriptChannels( dependencies.push(...(new Set(renderedDependencies) as any)); break; } - case 'http_client': { const topic = simpleContext.topic; const renders = []; const operations = channel.operations().all(); if (operations.length > 0) { for (const operation of operations) { + const shouldRenderRequest = shouldRenderFunctionType( + functionTypeMapping, + ChannelFunctionTypes.HTTP_CLIENT, + operation.action(), + generator.asyncapiReverseOperations + ); + if (!shouldRenderRequest) { + continue; + } const payloadId = findOperationId(operation, channel); const payload = payloads.operationModels[payloadId]; if (payload === undefined) { @@ -594,11 +602,12 @@ export async function generateTypeScriptChannels( const replyId = findReplyId(operation, reply, channel); const replyMessageModel = payloads.operationModels[replyId]; if (!replyMessageModel) { - continue; + throw new Error( + `Could not find payload for reply ${replyId} for channel typescript generator` + ); } const statusCodes = operation.reply()?.messages().all().map((value) => { const statusCode = Number(value.bindings().get('http')?.json()['statusCode']); - value.id(); return { code: statusCode, description: value.description() ?? 'Unknown', messageModule, messageType }; @@ -607,12 +616,6 @@ export async function generateTypeScriptChannels( messageModule: replyMessageModule, messageType: replyMessageType } = getMessageTypeAndModule(replyMessageModel); - const shouldRenderRequest = shouldRenderFunctionType( - functionTypeMapping, - ChannelFunctionTypes.HTTP_CLIENT, - operation.action(), - generator.asyncapiReverseOperations - ); if (shouldRenderRequest) { const httpMethod = operation.bindings().get('http')?.json()['method'] ?? 'GET'; renders.push( @@ -775,7 +778,6 @@ export async function generateTypeScriptChannels( dependencies.push(...(new Set(renderedDependencies) as any)); break; } - case 'event_source': { const topic = simpleContext.topic; let eventSourceContext: RenderRegularParameters = { diff --git a/src/codegen/generators/typescript/channels/protocols/http/common.ts b/src/codegen/generators/typescript/channels/protocols/http/common.ts index 259b58a4..0d08b05f 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/common.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/common.ts @@ -1,12 +1,9 @@ export function renderCommonHttpCode() { return `type RequestCredentials = "omit" | "include" | "same-origin"; -export type Json = any; export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; export type HTTPHeaders = { [key: string]: string }; export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; -export type HTTPBody = Json | URLSearchParams; -export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody }; - +export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: any }; export interface FetchCallbackResponse { ok: boolean, status: number, diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts index f825dd9a..ea362950 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -52,7 +52,7 @@ export function renderHttpClient({ headers: options.headers }) }, - basePath: '${addressToUse}', + basePath: ${addressToUse}, }, ...context, } @@ -72,7 +72,7 @@ export function renderHttpClient({ } const response = await parsedContext.fetch(url, { - method: ${method}, + method: '${method}', headers, body, credentials: parsedContext.credentials, @@ -88,7 +88,7 @@ export function renderHttpClient({ replyType, code, functionName, - dependencies: [`import { URLSearchParams } from 'url';`, `import * as NodeFetch from 'node-fetch'; `], + dependencies: [`import { URLSearchParams } from 'url';`, `import * as NodeFetch from 'node-fetch';`], functionType: ChannelFunctionTypes.HTTP_CLIENT }; } diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index 3477d50b..a6fa93db 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -791,382 +791,52 @@ await js.publish('user.signedup', dataToSend, options); exports[`channels typescript should work with request and reply AsyncAPI 1`] = ` "import * as TestPayloadModelModule from './../../../../TestPayloadModel'; -import * as Nats from 'nats'; -import * as Amqp from 'amqplib'; -import * as Mqtt from 'mqtt'; -import * as Kafka from 'kafkajs'; -import { fetchEventSource, EventStreamContentType, EventSourceMessage } from '@microsoft/fetch-event-source'; -import { NextFunction, Request, Response, Router } from 'express'; +import { URLSearchParams } from 'url'; +import * as NodeFetch from 'node-fetch'; export const Protocols = { -nats: { - /** - * NATS publish operation for \`ping\` - * - * @param message to publish - * @param nc the NATS client to publish from - * @param codec the serialization codec to use while transmitting the message - * @param options to use while publishing the message - */ -publishToPing: ( - message: MessageTypeModule.MessageType, nc: Nats.NatsConnection, codec: any = Nats.JSONCodec(), options?: Nats.PublishOptions -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); -dataToSend = codec.encode(dataToSend); -nc.publish('ping', dataToSend, options); - resolve(); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * Callback for when receiving messages - * - * @callback subscribeToPingCallback - * @param err if any error occurred this will be sat - * @param msg that was received - * @param natsMsg - */ - -/** - * Core subscription for \`ping\` - * - * @param {subscribeToPingCallback} onDataCallback to call when messages are received - * @param nc the NATS client to subscribe through - * @param codec the serialization codec to use while receiving the message - * @param options when setting up the subscription - */ -subscribeToPing: ( - onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, natsMsg?: Nats.Msg) => void, nc: Nats.NatsConnection, codec: any = Nats.JSONCodec(), options?: Nats.SubscriptionOptions -): Promise => { - return new Promise(async (resolve, reject) => { - try { - const subscription = nc.subscribe('ping', options); - - (async () => { - for await (const msg of subscription) { - - let receivedData: any = codec.decode(msg.data); -onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); - } - })(); - resolve(subscription); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * Callback for when receiving messages - * - * @callback jetStreamPullSubscribeToPingCallback - * @param err if any error occurred this will be sat - * @param msg that was received - * @param jetstreamMsg - */ - -/** - * JetStream pull subscription for \`ping\` - * - * @param {jetStreamPullSubscribeToPingCallback} onDataCallback to call when messages are received - * @param js the JetStream client to pull subscribe through - * @param options when setting up the subscription - * @param codec the serialization codec to use while receiving the message - */ -jetStreamPullSubscribeToPing: ( - onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, js: Nats.JetStreamClient, options: Nats.ConsumerOptsBuilder | Partial, codec: any = Nats.JSONCodec() -): Promise => { - return new Promise(async (resolve, reject) => { - try { - const subscription = await js.pullSubscribe('ping', options); - - (async () => { - for await (const msg of subscription) { - - let receivedData: any = codec.decode(msg.data); -onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); - } - })(); - resolve(subscription); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * Callback for when receiving messages - * - * @callback jetStreamPushSubscriptionFromPingCallback - * @param err if any error occurred this will be sat - * @param msg that was received - * @param jetstreamMsg - */ - -/** - * JetStream push subscription for \`ping\` - * - * @param {jetStreamPushSubscriptionFromPingCallback} onDataCallback to call when messages are received - * @param js the JetStream client to pull subscribe through - * @param options when setting up the subscription - * @param codec the serialization codec to use while receiving the message - */ -jetStreamPushSubscriptionFromPing: ( - onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, js: Nats.JetStreamClient, options: Nats.ConsumerOptsBuilder | Partial, codec: any = Nats.JSONCodec() -): Promise => { - return new Promise(async (resolve, reject) => { - try { - const subscription = await js.subscribe('ping', options); - - (async () => { - for await (const msg of subscription) { - - let receivedData: any = codec.decode(msg.data); -onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); - } - })(); - resolve(subscription); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * JetStream publish operation for \`ping\` - * - * @param message to publish over jetstream - * @param js the JetStream client to publish from - * @param codec the serialization codec to use while transmitting the message - * @param options to use while publishing the message - */ -jetStreamPublishToPing: ( - message: MessageTypeModule.MessageType, js: Nats.JetStreamClient, codec: any = Nats.JSONCodec(), options: Partial = {} -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); -dataToSend = codec.encode(dataToSend); -await js.publish('ping', dataToSend, options); - resolve(); - } catch (e: any) { - reject(e); - } - }); -} -}, -amqp: { - /** - * AMQP publish operation for exchange \`/ping\` - * - * @param message to publish - * @param amqp the AMQP connection to send over - - */ -publishToPingExchange: ( - message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: {exchange: string | undefined} & Amqp.Options.Publish -): Promise => { - return new Promise(async (resolve, reject) => { - const exchange = options?.exchange ?? 'undefined'; - if(!exchange) { - return reject('No exchange value found, please provide one') - } - try { - let dataToSend: any = MessageTypeModule.marshal(message); -const channel = await amqp.createChannel(); -const routingKey = '/ping'; -channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); - resolve(); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * AMQP publish operation for queue \`/ping\` - * - * @param message to publish - * @param amqp the AMQP connection to send over - - */ -publishToPingQueue: ( - message: MessageTypeModule.MessageType, amqp: Amqp.Connection, options?: Amqp.Options.Publish -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); -const channel = await amqp.createChannel(); -const queue = '/ping'; -channel.sendToQueue(queue, Buffer.from(dataToSend), options); - resolve(); - } catch (e: any) { - reject(e); - } - }); -} -}, -mqtt: { - /** - * MQTT publish operation for \`/ping\` - * - * @param message to publish - * @param mqtt the MQTT client to publish from - */ -publishToPing: ( - message: MessageTypeModule.MessageType, mqtt: Mqtt.MqttClient -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); -mqtt.publish('/ping', dataToSend); - resolve(); - } catch (e: any) { - reject(e); - } - }); -} -}, -kafka: { - /** - * Kafka publish operation for \`ping\` - * - * @param message to publish - * @param kafka the KafkaJS client to publish from - */ -produceToPing: ( - message: MessageTypeModule.MessageType, kafka: Kafka.Kafka -): Promise => { - return new Promise(async (resolve, reject) => { - try { - let dataToSend: any = MessageTypeModule.marshal(message); - const producer = kafka.producer(); - await producer.connect(); - await producer.send({ - topic: 'ping', - messages: [ - { value: dataToSend }, - ], - }); - resolve(producer); - } catch (e: any) { - reject(e); - } - }); -}, -/** - * Callback for when receiving messages - * - * @callback consumeFromPingCallback - * @param err if any error occurred this will be sat - * @param msg that was received - * @param kafkaMsg - */ - -/** - * Kafka subscription for \`ping\` - * - * @param {consumeFromPingCallback} onDataCallback to call when messages are received - * @param kafka the KafkaJS client to subscribe through - * @param options when setting up the subscription - */ -consumeFromPing: ( - onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, kafkaMsg?: Kafka.EachMessagePayload) => void, kafka: Kafka.Kafka, options: {fromBeginning: boolean, groupId: string} = {fromBeginning: true, groupId: ''} -): Promise => { - return new Promise(async (resolve, reject) => { - try { - if(!options.groupId) { - reject('No group ID provided'); - } - const consumer = kafka.consumer({ groupId: options.groupId }); - - await consumer.connect(); - await consumer.subscribe({ topic: 'ping', fromBeginning: options.fromBeginning }); - await consumer.run({ - eachMessage: async (kafkaMessage: Kafka.EachMessagePayload) => { - const { topic, message } = kafkaMessage; - - const callbackData = MessageTypeModule.unmarshal(message.value?.toString()!); - onDataCallback(undefined, callbackData, kafkaMessage); - } - }); - resolve(consumer); - } catch (e: any) { - reject(e); - } - }); -} -}, -event_source: { - /** - * Event source fetch for \`/ping\` - * - * @param callback to call when receiving events - * @param options additionally used to handle the event source - */ -listenForPing: async ( - callback: (messageEvent: MessageTypeModule.MessageType | null, error?: string) => void, - options: {authorization?: string, onClose?: (err?: string) => void, baseUrl: string} -) => { - let eventsUrl: string = '/ping'; - const url = \`\${options.baseUrl}/\${eventsUrl}\` - const headers: Record = { - Accept: 'text/event-stream', +http_client: { + async getPingRequest(context: RequestContext): Promise { + const parsedContext: InternalRequestContext = { + ...{ + fetch: async (url, options) => { + return NodeFetch.default(url, { + body: options.body, + method: options.method, + headers: options.headers + }) + }, + basePath: ''/ping'', + }, + ...context, } - if(options.authorization) { - headers['authorization'] = \`Bearer \${options?.authorization}\`; + const headers: HTTPHeaders = { + 'Content-Type': 'application/json', + ...parsedContext.additionalHeaders + }; + const url = parsedContext.server ?? parsedContext.basePath; + + let body: any; + if (parsedContext.payload) { + body = parsedContext.payload.marshal(); } - await fetchEventSource(\`\${url}\`, { - method: 'GET', - headers, - onmessage: (ev: EventSourceMessage) => { - const callbackData = MessageTypeModule.unmarshal(ev.data); - callback(callbackData, undefined); - }, - onerror: (err) => { - options.onClose?.(err); - }, - onclose: () => { - options.onClose?.(); - }, - async onopen(response: { ok: any; headers: { get: (arg0: string) => any }; status: number }) { - if (response.ok && response.headers.get('content-type') === EventStreamContentType) { - return // everything's good - } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { - // client-side errors are usually non-retriable: - callback(null, 'Client side error, could not open event connection') - } else { - callback(null, 'Unknown error, could not open event connection'); - } - }, - }) + if (parsedContext.accessToken) { + // oauth required + headers["Authorization"] = typeof parsedContext.accessToken === 'string' ? parsedContext.accessToken : await parsedContext.accessToken("petstore_auth", ["write:pets", "read:pets"]); + } + + const response = await parsedContext.fetch(url, { + method: GET, + headers, + body, + credentials: parsedContext.credentials, + }); + if (response.ok) { + const data = await response.json(); + return MessageTypeModule.MessageType.unmarshal(data); + } else if (response.status === 200) { + return Promise.reject(new FetchError(new Error(response.statusText), response.status, 'Unknown')); } -, -registerPing: ( - router: Router, - callback: ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => void) | ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => Promise) -) => { - const event = '/ping'; - router.get(event, async (req, res, next) => { - - res.writeHead(200, { - 'Cache-Control': 'no-cache, no-transform', - 'Content-Type': 'text/event-stream', - Connection: 'keep-alive', - 'Access-Control-Allow-Origin': '*', - }) - const sendEventCallback = (message: MessageTypeModule.MessageType) => { - if (res.closed) { - return - } - res.write(\`event: \${event}\\n\`) - res.write(\`data: \${MessageTypeModule.marshal(message)}\\n\\n\`) - } - await callback(req, res, next, sendEventCallback) - }) + return Promise.reject(new Error(response.statusText)); } - -}, -http_client: { - }};" `; diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index 6b291506..42638e8f 100644 --- a/test/codegen/generators/typescript/channels.spec.ts +++ b/test/codegen/generators/typescript/channels.spec.ts @@ -68,7 +68,15 @@ describe('channels', () => { pingRequest: { messageModel: payloadModel, messageType: 'MessageType' - } + }, + pongResponse: { + messageModel: payloadModel, + messageType: 'MessageType' + }, + pingRequest_reply: { + messageModel: payloadModel, + messageType: 'MessageType' + }, }, otherModels: [], generator: {outputPath: './test'} as any @@ -78,8 +86,8 @@ describe('channels', () => { ...defaultTypeScriptChannelsGenerator, outputPath: path.resolve(__dirname, './output'), id: 'test', - asyncapiGenerateForOperations: false, - protocols: ['nats', 'amqp', 'mqtt', 'kafka', 'event_source', 'http_client'] + asyncapiGenerateForOperations: true, + protocols: ['http_client'] }, inputType: 'asyncapi', asyncapiDocument: parsedAsyncAPIDocument, diff --git a/test/configs/asyncapi-request.yaml b/test/configs/asyncapi-request.yaml index 10ef14a2..911546e2 100644 --- a/test/configs/asyncapi-request.yaml +++ b/test/configs/asyncapi-request.yaml @@ -13,6 +13,20 @@ channels: $ref: '#/components/messages/pong' operations: pingRequest: + action: send + channel: + $ref: '#/channels/ping' + messages: + - $ref: '#/channels/ping/messages/ping' + reply: + channel: + $ref: '#/channels/ping' + messages: + - $ref: '#/channels/ping/messages/pong' + bindings: + http: + method: GET + pongResponse: action: receive channel: $ref: '#/channels/ping' diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json index ba4a602c..13114fd4 100644 --- a/test/runtime/typescript/package.json +++ b/test/runtime/typescript/package.json @@ -5,7 +5,9 @@ "test:nats": "jest -- ./test/nats.spec.ts", "test:mqtt": "jest -- ./test/mqtt.spec.ts", "test:amqp": "jest -- ./test/amqp.spec.ts", - "generate": "node ../../../bin/run.mjs generate" + "generate": "npm run generate:regular && npm run generate:request:reply", + "generate:request:reply": "node ../../../bin/run.mjs generate ./codegen-request-reply.mjs", + "generate:regular": "node ../../../bin/run.mjs generate" }, "dependencies": { "@ai-zen/node-fetch-event-source": "^2.1.4", diff --git a/test/runtime/typescript/test/http_fetch.spec.ts b/test/runtime/typescript/test/http_fetch.spec.ts deleted file mode 100644 index 4017a1c6..00000000 --- a/test/runtime/typescript/test/http_fetch.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* eslint-disable no-console */ -import { Protocols } from '../src/channels/index'; -import { Ping } from "../src/payloads/Ping"; -import { Pong } from "../src/payloads/Pong"; -const { http_client } = Protocols; -const { } = http_client; -import http from 'http' - -describe('http_fetch', () => { - describe('channels', () => { - it('should be able to make POST request', async () => { - return new Promise(async (resolve, reject) => { - const requestMessage = new Ping({}) - const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}) - const httpServer = http.createServer((req, res) => { - let body = '' - req.on('data', function(data) { - body += data - }) - req.on('end', function() { - try { - expect(req.method).toEqual('POST'); - expect(Ping.unmarshal(body).marshal()).toEqual(requestMessage.marshal()) - } catch(e) { - reject(e); - } - res.write(replyMessage.marshal()); - res.end(); - }) - }).listen(8080); - const receivedReplyMessage = await postPing({payload: requestMessage, basePath: 'http://localhost:8080'}) - expect(receivedReplyMessage.marshal()).toEqual(replyMessage.marshal()) - httpServer.close(); - resolve(); - }); - }); - }); -}); From 8f8841a075edcc821b546717da9b565ec8d6f597 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 13 Feb 2025 23:02:04 +0100 Subject: [PATCH 08/19] fix impl --- .../typescript/codegen-request-reply.mjs | 31 +++++++++++++++ .../typescript/test/http_client.spec.ts | 39 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 test/runtime/typescript/codegen-request-reply.mjs create mode 100644 test/runtime/typescript/test/http_client.spec.ts diff --git a/test/runtime/typescript/codegen-request-reply.mjs b/test/runtime/typescript/codegen-request-reply.mjs new file mode 100644 index 00000000..35020e35 --- /dev/null +++ b/test/runtime/typescript/codegen-request-reply.mjs @@ -0,0 +1,31 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'asyncapi', + inputPath: '../asyncapi.json', + language: 'typescript', + generators: [ + { + preset: 'payloads', + outputPath: './src/request-reply/payloads', + serializationType: 'json', + }, + { + preset: 'parameters', + outputPath: './src/request-reply/parameters', + }, + { + preset: 'headers', + outputPath: './src/request-reply/headers', + }, + { + preset: 'channels', + outputPath: './src/request-reply/channels', + protocols: ['nats', 'http_client'] + }, + { + preset: 'client', + outputPath: './src/request-reply/client', + protocols: ['nats'] + } + ] +}; diff --git a/test/runtime/typescript/test/http_client.spec.ts b/test/runtime/typescript/test/http_client.spec.ts new file mode 100644 index 00000000..c6d3dc01 --- /dev/null +++ b/test/runtime/typescript/test/http_client.spec.ts @@ -0,0 +1,39 @@ +/* eslint-disable no-console */ +import { Protocols } from '../src/request-reply/channels/index'; +import { Ping } from "../src/request-reply/payloads/Ping"; +import { Pong } from "../src/request-reply/payloads/Pong"; +import express, { Router } from 'express'; +const {http_client } = Protocols; +const {getPingRequest } = http_client; + +jest.setTimeout(10000); +describe('http_fetch', () => { + const portToUse = Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779; + describe('channels', () => { + let server; + afterEach(() => { + server?.close(); + }); + it('should be able to make GET request', async () => { + return new Promise(async (resolve, reject) => { + const router = Router(); + const app = express(); + app.use(express.json({ limit: '3000kb' })); + app.use(express.urlencoded({ extended: true })); + app.use(router); + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + + router.post('/ping', (req, res) => { + res.write(replyMessage.marshal()); + res.end(); + }); + server = app.listen(portToUse, async () => { + const receivedReplyMessage = await getPingRequest({payload: requestMessage, server: `http://localhost:${ portToUse}`}); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + resolve(); + }); + }); + }); + }); +}); From 0c0272686dbebaee7098f5da82285d9c1ebf58cb Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 15 Feb 2025 10:34:56 +0100 Subject: [PATCH 09/19] fix generator --- src/codegen/generators/typescript/channels/index.ts | 6 ++++++ .../generators/typescript/channels/protocols/http/common.ts | 4 ++-- .../generators/typescript/channels/protocols/http/index.ts | 4 ++-- test/runtime/typescript/test/http_client.spec.ts | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/codegen/generators/typescript/channels/index.ts b/src/codegen/generators/typescript/channels/index.ts index e2068465..9d9457ad 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -46,6 +46,7 @@ import { getMessageTypeAndModule } from './utils'; import { renderHttpClient } from './protocols/http'; +import { renderCommonHttpCode } from './protocols/http/common'; export { renderedFunctionType, TypeScriptChannelRenderType, @@ -87,6 +88,9 @@ export async function generateTypeScriptChannels( } const protocolCodeFunctions: Record = {}; + + // Render before renders + const coreCode: string[] = []; const externalProtocolFunctionInformation: Record< string, renderedFunctionType[] @@ -588,6 +592,7 @@ export async function generateTypeScriptChannels( if (!shouldRenderRequest) { continue; } + coreCode.push(renderCommonHttpCode()); const payloadId = findOperationId(operation, channel); const payload = payloads.operationModels[payloadId]; if (payload === undefined) { @@ -917,6 +922,7 @@ export async function generateTypeScriptChannels( const dependenciesToRender = [...new Set(dependencies)]; await mkdir(context.generator.outputPath, {recursive: true}); const result = `${dependenciesToRender.join('\n')} +${[...new Set(coreCode)].join('\n')} export const Protocols = { ${Object.entries(protocolCodeFunctions) .map(([protocol, functions]) => { diff --git a/src/codegen/generators/typescript/channels/protocols/http/common.ts b/src/codegen/generators/typescript/channels/protocols/http/common.ts index 0d08b05f..6765ad8f 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/common.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/common.ts @@ -15,11 +15,11 @@ export type FetchCallback = (url: string, options: HTTPRequestInit) => FetchCall export interface RequestContext { payload: RequestPayload, basePath?: string; // override base path - server?: 'http://localhost:3000' //username?: string; // parameter for basic security //password?: string; // parameter for basic security //apiKey?: string | ((name: string) => string | Promise); // parameter for apiKey security - accessToken?: string | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security + //accessToken?: string | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security + accessToken?: string; credentials?: RequestCredentials; //value for the credentials param we want to use on each request additionalHeaders?: HTTPHeaders; //header params we want to use on every request, fetch?: FetchCallback diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts index ea362950..e7de7b58 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -37,7 +37,7 @@ export function renderHttpClient({ const replyType = replyMessageModule ? `${replyMessageModule}.${replyMessageType}` : replyMessageType; - const statusCodeChecks = statusCodes.map((value) => { + const statusCodeChecks = statusCodes.filter((value) => value.code < 200 && value.code >= 300).map((value) => { return `else if (response.status === ${value.code}) { return Promise.reject(new FetchError(new Error(response.statusText), response.status, '${value.description}')); }`; @@ -68,7 +68,7 @@ export function renderHttpClient({ } if (parsedContext.accessToken) { // oauth required - headers["Authorization"] = typeof parsedContext.accessToken === 'string' ? parsedContext.accessToken : await parsedContext.accessToken("petstore_auth", ["write:pets", "read:pets"]); + headers["Authorization"] = parsedContext.accessToken; } const response = await parsedContext.fetch(url, { diff --git a/test/runtime/typescript/test/http_client.spec.ts b/test/runtime/typescript/test/http_client.spec.ts index c6d3dc01..61a81335 100644 --- a/test/runtime/typescript/test/http_client.spec.ts +++ b/test/runtime/typescript/test/http_client.spec.ts @@ -29,7 +29,7 @@ describe('http_fetch', () => { res.end(); }); server = app.listen(portToUse, async () => { - const receivedReplyMessage = await getPingRequest({payload: requestMessage, server: `http://localhost:${ portToUse}`}); + const receivedReplyMessage = await getPingRequest({payload: requestMessage, basePath: `http://localhost:${ portToUse}`}); expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); resolve(); }); From a619b4966b4723752ce2116f87e7231c8f965f57 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 17 May 2025 12:59:17 +0200 Subject: [PATCH 10/19] wip --- docs/protocols/http.md | 19 +- .../typescript/channels/asyncapi.ts | 10 + .../channels/protocols/http/common.ts | 47 - .../channels/protocols/http/fetch.ts | 55 +- .../channels/protocols/http/index.ts | 48 +- .../generators/typescript/channels/types.ts | 6 +- src/codegen/types.ts | 9 + .../__snapshots__/channels.spec.ts.snap | 1142 +++++++++++++++++ .../__snapshots__/payload.spec.ts.snap | 256 ++++ test/runtime/asyncapi-http.json | 119 ++ test/runtime/asyncapi.json | 4 +- test/runtime/typescript/package-lock.json | 39 +- .../test/{ => channels}/http_client.spec.ts | 13 +- 13 files changed, 1658 insertions(+), 109 deletions(-) delete mode 100644 src/codegen/generators/typescript/channels/protocols/http/common.ts create mode 100644 test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap create mode 100644 test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap create mode 100644 test/runtime/asyncapi-http.json rename test/runtime/typescript/test/{ => channels}/http_client.spec.ts (68%) diff --git a/docs/protocols/http.md b/docs/protocols/http.md index 6a9e0ab3..e26e8c61 100644 --- a/docs/protocols/http.md +++ b/docs/protocols/http.md @@ -30,13 +30,12 @@ All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP | Bearer Authentication | ➗ | ➗ | | Basic Authentication | ➗ | ➗ | | API Key Authentication | ➗ | ➗ | -| JSON Based API | ➗ | ➗ | -| POST | ➗ | ➗ | -| GET | ➗ | ➗ | -| PATCH | ➗ | ➗ | -| DELETE | ➗ | ➗ | -| PUT | ➗ | ➗ | -| HEAD | ➗ | ➗ | -| OPTIONS | ➗ | ➗ | -| TRACE | ➗ | ➗ | -| CONNECT | ➗ | ➗ | +| XML Based API | ➗ | ➗ | +| JSON Based API | ✔️ | ➗ | +| POST | ✔️ | ➗ | +| GET | ✔️ | ➗ | +| PATCH | ✔️ | ➗ | +| DELETE | ✔️ | ➗ | +| PUT | ✔️ | ➗ | +| HEAD | ✔️ | ➗ | +| OPTIONS | ✔️ | ➗ | \ No newline at end of file diff --git a/src/codegen/generators/typescript/channels/asyncapi.ts b/src/codegen/generators/typescript/channels/asyncapi.ts index fd6a8ab3..9d11e85a 100644 --- a/src/codegen/generators/typescript/channels/asyncapi.ts +++ b/src/codegen/generators/typescript/channels/asyncapi.ts @@ -19,6 +19,7 @@ import {generateKafkaChannels} from './protocols/kafka'; import {generateMqttChannels} from './protocols/mqtt'; import {generateAmqpChannels} from './protocols/amqp'; import {generateEventSourceChannels} from './protocols/eventsource'; +import { generatehttpChannels } from './protocols/http'; type Action = 'send' | 'receive' | 'subscribe' | 'publish'; const sendingFunctionTypes = [ @@ -162,6 +163,15 @@ export async function generateTypeScriptChannelsForAsyncAPI( dependencies ); break; + case 'http_client': + await generatehttpChannels( + protocolContext, + channel, + protocolCodeFunctions, + externalProtocolFunctionInformation, + dependencies + ); + break; case 'event_source': await generateEventSourceChannels( protocolContext, diff --git a/src/codegen/generators/typescript/channels/protocols/http/common.ts b/src/codegen/generators/typescript/channels/protocols/http/common.ts deleted file mode 100644 index 6765ad8f..00000000 --- a/src/codegen/generators/typescript/channels/protocols/http/common.ts +++ /dev/null @@ -1,47 +0,0 @@ -export function renderCommonHttpCode() { - return `type RequestCredentials = "omit" | "include" | "same-origin"; -export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; -export type HTTPHeaders = { [key: string]: string }; -export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; -export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: any }; -export interface FetchCallbackResponse { - ok: boolean, - status: number, - statusText: string, - json: () => Record | Promise>, -} -export type FetchCallback = (url: string, options: HTTPRequestInit) => FetchCallbackResponse | Promise; - -export interface RequestContext { - payload: RequestPayload, - basePath?: string; // override base path - //username?: string; // parameter for basic security - //password?: string; // parameter for basic security - //apiKey?: string | ((name: string) => string | Promise); // parameter for apiKey security - //accessToken?: string | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security - accessToken?: string; - credentials?: RequestCredentials; //value for the credentials param we want to use on each request - additionalHeaders?: HTTPHeaders; //header params we want to use on every request, - fetch?: FetchCallback -} -export interface InternalRequestContext { - payload: RequestPayload, - basePath: string; // override base path - server?: 'http://localhost:3000' - //username?: string; // parameter for basic security - //password?: string; // parameter for basic security - //apiKey?: string | ((name: string) => string | Promise); // parameter for apiKey security - accessToken?: string | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security - credentials?: RequestCredentials; //value for the credentials param we want to use on each request - additionalHeaders?: HTTPHeaders; //header params we want to use on every request, - fetch: FetchCallback -} -export class FetchError extends Error { - override name = "FetchError"; - code: number; - constructor(public cause: Error, code: number, msg: string) { - super(msg); - this.code = code; - } -}`; -} diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index f76c7c39..c614ce2d 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -1,4 +1,4 @@ -import { SingleFunctionRenderType } from "../../../../../types"; +import { HttpRenderType } from "../../../../../types"; import { pascalCase } from "../../../utils"; import { ChannelFunctionTypes, RenderHttpParameters } from "../../types"; @@ -11,9 +11,10 @@ export function renderHttpFetchClient({ channelParameters, method, statusCodes = [], + servers = [], subName = pascalCase(requestTopic), functionName = `${method.toLowerCase()}${subName}`, -}: RenderHttpParameters): SingleFunctionRenderType { +}: RenderHttpParameters): HttpRenderType { const addressToUse = channelParameters ? `parameters.getChannelWithParameters('${requestTopic}')` : `'${requestTopic}'`; @@ -28,40 +29,62 @@ export function renderHttpFetchClient({ return Promise.reject(new FetchError(new Error(response.statusText), response.status, '${value.description}')); }`; }); - const code = `async ${functionName}(context: RequestContext<${messageType}>): Promise<${replyType}> { - const parsedContext: InternalRequestContext<${messageType}> = { + const code = `async ${functionName}(context: { + server?: ${[...servers.map((value) => `'${value}'`), 'string'].join(' | ')}; + ${messageType ?? `payload: ${messageType};`} + path?: string; + accessToken?: string; + credentials?: RequestCredentials; //value for the credentials param we want to use on each request + additionalHeaders?: Record; //header params we want to use on every request, + makeRequestCallback?: ({ + method, body, url, headers + }: { + url: string, + headers?: Record, + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD', + credentials?: RequestCredentials, + body?: any + }) => Promise<{ + ok: boolean, + status: number, + statusText: string, + json: () => Record | Promise>, + }> + }): Promise<${replyType}> { + const parsedContext = { ...{ - fetch: async (url, options) => { + makeRequestCallback: async ({url, body, method, headers}) => { return NodeFetch.default(url, { - body: options.body, - method: options.method, - headers: options.headers + body, + method, + headers }) }, - basePath: ${addressToUse}, + path: ${addressToUse}, + server: ${servers[0] ?? 'localhost:3000'}, }, ...context, } - const headers: HTTPHeaders = { + const headers = { 'Content-Type': 'application/json', ...parsedContext.additionalHeaders }; - const url = parsedContext.server ?? parsedContext.basePath; + const url = \`\${parsedContext.server}\${parsedContext.path}\`; let body: any; - if (parsedContext.payload) { + ${messageType ?? `if (parsedContext.payload) { body = parsedContext.payload.marshal(); - } + }`} + if (parsedContext.accessToken) { // oauth required headers["Authorization"] = parsedContext.accessToken; } - const response = await parsedContext.fetch(url, { + const response = await parsedContext.makeRequestCallback({url, method: '${method}', headers, - body, - credentials: parsedContext.credentials, + body }); if (response.ok) { const data = await response.json(); diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts index 8f1e8d33..cb980ff9 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -4,14 +4,18 @@ import { ChannelFunctionTypes, TypeScriptChannelsGeneratorContext } from '../../types'; -import {findNameFromOperation, findOperationId, findReplyId} from '../../../../../utils'; +import { + findNameFromOperation, + findOperationId, + findReplyId +} from '../../../../../utils'; import {getMessageTypeAndModule} from '../../utils'; import { shouldRenderFunctionType, getFunctionTypeMappingFromAsyncAPI } from '../../asyncapi'; import {ChannelInterface} from '@asyncapi/parser'; -import {SingleFunctionRenderType} from '../../../../../types'; +import {HttpRenderType, SingleFunctionRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {renderHttpFetchClient} from './fetch'; @@ -53,9 +57,9 @@ function addRendersToExternal( dependencies: string[], parameter?: ConstrainedObjectModel ) { - protocolCodeFunctions['http'].push(...renders.map((value) => value.code)); + protocolCodeFunctions['http_client'].push(...renders.map((value) => value.code)); - externalProtocolFunctionInformation['http'].push( + externalProtocolFunctionInformation['http_client'].push( ...renders.map((value) => ({ functionType: value.functionType, functionName: value.functionName, @@ -76,8 +80,8 @@ function generateForOperations( channel: ChannelInterface, topic: string, parameters: ConstrainedObjectModel | undefined -): SingleFunctionRenderType[] { - const renders: SingleFunctionRenderType[] = []; +): HttpRenderType[] { + const renders: HttpRenderType[] = []; const {generator, payloads} = context; const functionTypeMapping = generator.functionTypeMapping[channel.id()]; @@ -129,22 +133,22 @@ function generateForOperations( messageModule: replyMessageModule, messageType: replyMessageType } = getMessageTypeAndModule(replyMessageModel); - const httpMethod = - operation.bindings().get('http')?.json()['method'] ?? 'GET'; - renders.push( - renderHttpFetchClient({ - subName: findNameFromOperation(operation, channel), - requestMessageModule: messageModule, - requestMessageType: messageType, - replyMessageModule, - replyMessageType, - requestTopic: topic, - method: httpMethod.toUpperCase(), - statusCodes, - channelParameters: - parameters !== undefined ? (parameters as any) : undefined - }) - ); + const httpMethod = + operation.bindings().get('http')?.json()['method'] ?? 'GET'; + renders.push( + renderHttpFetchClient({ + subName: findNameFromOperation(operation, channel), + requestMessageModule: httpMethod === 'POST' ? messageModule : undefined, + requestMessageType: httpMethod === 'POST' ? messageType : undefined, + replyMessageModule, + replyMessageType, + requestTopic: topic, + method: httpMethod.toUpperCase(), + statusCodes, + channelParameters: + parameters !== undefined ? (parameters as any) : undefined + }) + ); } } } diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index d5d56a02..ffe524c1 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -17,6 +17,7 @@ export enum ChannelFunctionTypes { KAFKA_PUBLISH = 'kafka_publish', KAFKA_SUBSCRIBE = 'kafka_subscribe', AMQP_QUEUE_PUBLISH = 'amqp_queue_publish', + AMQP_QUEUE_SUBSCRIBE = 'amqp_queue_subscribe', AMQP_EXCHANGE_PUBLISH = 'amqp_exchange_publish', HTTP_CLIENT = 'http_client', EVENT_SOURCE_FETCH = 'event_source_fetch', @@ -156,8 +157,9 @@ export interface RenderRequestReplyParameters { export interface RenderHttpParameters { requestTopic: string; - requestMessageType: string; - requestMessageModule: string | undefined; + requestMessageType?: string; + servers?: string[]; + requestMessageModule?: string | undefined; replyMessageType: string; replyMessageModule: string | undefined; channelParameters: ConstrainedObjectModel | undefined; diff --git a/src/codegen/types.ts b/src/codegen/types.ts index d257fe83..542e45d3 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -132,6 +132,15 @@ export interface SingleFunctionRenderType { replyType?: string; } +export interface HttpRenderType { + functionName: string; + code: string; + dependencies: string[]; + functionType: ChannelFunctionTypes; + messageType?: string; + replyType: string; +} + export const zodAsyncAPICodegenConfiguration = z.object({ $schema: z .string() diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap new file mode 100644 index 00000000..bca87d4e --- /dev/null +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -0,0 +1,1142 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`channels typescript should work with basic AsyncAPI inputs 1`] = ` +"import * as TestPayloadModelModule from './../../../../TestPayloadModel'; +import {TestParameter} from './../../../../TestParameter'; +import * as Nats from 'nats'; +import * as Amqp from 'amqplib'; +import * as Mqtt from 'mqtt'; +import * as Kafka from 'kafkajs'; +import { fetchEventSource, EventStreamContentType, EventSourceMessage } from '@microsoft/fetch-event-source'; +import { NextFunction, Request, Response, Router } from 'express'; +export const Protocols = { +nats: { + /** + * NATS publish operation for \`user.signedup\` + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +publishToUserSignedup: ({ + message, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: MessageTypeModule.MessageType, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +nc.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback subscribeToUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Core subscription for \`user.signedup\` + * + * @param {subscribeToUserSignedupCallback} onDataCallback to call when messages are received + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +subscribeToUserSignedup: ({ + onDataCallback, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, natsMsg?: Nats.Msg) => void, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); + +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for \`user.signedup\` + * + * @param {jetStreamPullSubscribeToUserSignedupCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +jetStreamPullSubscribeToUserSignedup: ({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); + +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for \`user.signedup\` + * + * @param {jetStreamPushSubscriptionFromUserSignedupCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +jetStreamPushSubscriptionFromUserSignedup: ({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); + +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * JetStream publish operation for \`user.signedup\` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +jetStreamPublishToUserSignedup: ({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: MessageTypeModule.MessageType, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +await js.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +amqp: { + /** + * AMQP publish operation for exchange \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + * @param options for the AMQP publish exchange operation + */ +publishToUserSignedupExchange: ({ + message, + amqp, + options +}: { + message: MessageTypeModule.MessageType, + amqp: Amqp.Connection, + options?: {exchange: string | undefined} & Amqp.Options.Publish +}): Promise => { + return new Promise(async (resolve, reject) => { + const exchange = options?.exchange ?? 'undefined'; + if(!exchange) { + return reject('No exchange value found, please provide one') + } + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const routingKey = 'user/signedup'; +channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * AMQP publish operation for queue \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + * @param options for the AMQP publish queue operation + */ +publishToUserSignedupQueue: ({ + message, + amqp, + options +}: { + message: MessageTypeModule.MessageType, + amqp: Amqp.Connection, + options?: Amqp.Options.Publish +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const queue = 'user/signedup'; +channel.sendToQueue(queue, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * AMQP subscribe operation for queue \`user/signedup\` + * + * @param {subscribeToUserSignedupQueueCallback} onDataCallback to call when messages are received + * @param amqp the AMQP connection to receive from + * @param options for the AMQP subscribe queue operation + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +subscribeToUserSignedupQueue: ({ + onDataCallback, + amqp, + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, amqpMsg?: Amqp.ConsumeMessage) => void, + amqp: Amqp.Connection, + options?: Amqp.Options.Consume, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + const channel = await amqp.createChannel(); +const queue = 'user/signedup'; +await channel.assertQueue(queue, { durable: true }); + +channel.consume(queue, (msg) => { + if (msg !== null) { + const receivedData = msg.content.toString() + + const message = MessageTypeModule.unmarshal(receivedData); + onDataCallback(undefined, message, msg); + } +}, options); + resolve(channel); + } catch (e: any) { + reject(e); + } + }); +} +}, +mqtt: { + /** + * MQTT publish operation for \`user/signedup\` + * + * @param message to publish + * @param mqtt the MQTT client to publish from + */ +publishToUserSignedup: ({ + message, + mqtt +}: { + message: MessageTypeModule.MessageType, + mqtt: Mqtt.MqttClient +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +mqtt.publish('user/signedup', dataToSend); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +kafka: { + /** + * Kafka publish operation for \`user.signedup\` + * + * @param message to publish + * @param kafka the KafkaJS client to publish from + */ +produceToUserSignedup: ({ + message, + kafka +}: { + message: MessageTypeModule.MessageType, + kafka: Kafka.Kafka +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); + const producer = kafka.producer(); + await producer.connect(); + await producer.send({ + topic: 'user.signedup', + messages: [ + { value: dataToSend }, + ], + }); + resolve(producer); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback consumeFromUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param kafkaMsg + */ + +/** + * Kafka subscription for \`user.signedup\` + * + * @param {consumeFromUserSignedupCallback} onDataCallback to call when messages are received + * @param kafka the KafkaJS client to subscribe through + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +consumeFromUserSignedup: ({ + onDataCallback, + kafka, + options = {fromBeginning: true, groupId: ''}, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, kafkaMsg?: Kafka.EachMessagePayload) => void, + kafka: Kafka.Kafka, + options: {fromBeginning: boolean, groupId: string}, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + if(!options.groupId) { + return reject('No group ID provided'); + } + const consumer = kafka.consumer({ groupId: options.groupId }); + + + await consumer.connect(); + await consumer.subscribe({ topic: 'user.signedup', fromBeginning: options.fromBeginning }); + await consumer.run({ + eachMessage: async (kafkaMessage: Kafka.EachMessagePayload) => { + const { topic, message } = kafkaMessage; + const receivedData = message.value?.toString()!; + + +const callbackData = MessageTypeModule.unmarshal(receivedData); +onDataCallback(undefined, callbackData, kafkaMessage); + } + }); + resolve(consumer); + } catch (e: any) { + reject(e); + } + }); +} +}, +event_source: { + /** + * Event source fetch for \`user/signedup\` + * + * @param callback to call when receiving events + * @param options additionally used to handle the event source + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +listenForUserSignedup: async ({ + callback, + options, + skipMessageValidation = false +}: { + callback: (error?: Error, messageEvent?: MessageTypeModule.MessageType) => void, + options: {authorization?: string, onClose?: (err?: string) => void, baseUrl: string, headers?: Record}, + skipMessageValidation?: boolean +}) => { + let eventsUrl: string = 'user/signedup'; + const url = \`\${options.baseUrl}/\${eventsUrl}\` + const headers: Record = { + ...options.headers ?? {}, + Accept: 'text/event-stream' + } + if(options.authorization) { + headers['authorization'] = \`Bearer \${options?.authorization}\`; + } + + await fetchEventSource(\`\${url}\`, { + method: 'GET', + headers, + onmessage: (ev: EventSourceMessage) => { + const receivedData = ev.data; + + const callbackData = MessageTypeModule.unmarshal(receivedData); + callback(undefined, callbackData); + }, + onerror: (err) => { + options.onClose?.(err); + }, + onclose: () => { + options.onClose?.(); + }, + async onopen(response: { ok: any; headers: any; status: number }) { + if (response.ok && response.headers.get('content-type') === EventStreamContentType) { + return // everything's good + } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { + // client-side errors are usually non-retriable: + callback(new Error('Client side error, could not open event connection')) + } else { + callback(new Error('Unknown error, could not open event connection')); + } + }, + }) +} +, +registerUserSignedup: ({ + router, + callback +}: { + router: Router, + callback: ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => void) | ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => Promise) +}) => { + const event = '/user/signedup'; + router.get(event, async (req, res, next) => { + + res.writeHead(200, { + 'Cache-Control': 'no-cache, no-transform', + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }) + const sendEventCallback = (message: MessageTypeModule.MessageType) => { + if (res.closed) { + return + } + res.write(\`event: \${event}\\n\`) + res.write(\`data: \${MessageTypeModule.marshal(message)}\\n\\n\`) + } + await callback(req, res, next, sendEventCallback) + }) +} + +}};" +`; + +exports[`channels typescript should work with basic AsyncAPI inputs with no parameters 1`] = ` +"import * as TestPayloadModelModule from './../../../../TestPayloadModel'; +import * as Nats from 'nats'; +import * as Amqp from 'amqplib'; +import * as Mqtt from 'mqtt'; +import * as Kafka from 'kafkajs'; +import { fetchEventSource, EventStreamContentType, EventSourceMessage } from '@microsoft/fetch-event-source'; +import { NextFunction, Request, Response, Router } from 'express'; +export const Protocols = { +nats: { + /** + * NATS publish operation for \`user.signedup\` + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +publishToUserSignedup: ({ + message, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: MessageTypeModule.MessageType, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +nc.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback subscribeToUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Core subscription for \`user.signedup\` + * + * @param {subscribeToUserSignedupCallback} onDataCallback to call when messages are received + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +subscribeToUserSignedup: ({ + onDataCallback, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, natsMsg?: Nats.Msg) => void, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); + +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for \`user.signedup\` + * + * @param {jetStreamPullSubscribeToUserSignedupCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +jetStreamPullSubscribeToUserSignedup: ({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); + +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for \`user.signedup\` + * + * @param {jetStreamPushSubscriptionFromUserSignedupCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +jetStreamPushSubscriptionFromUserSignedup: ({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('user.signedup', options); + + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); + +onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * JetStream publish operation for \`user.signedup\` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +jetStreamPublishToUserSignedup: ({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: MessageTypeModule.MessageType, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +await js.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +amqp: { + /** + * AMQP publish operation for exchange \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + * @param options for the AMQP publish exchange operation + */ +publishToUserSignedupExchange: ({ + message, + amqp, + options +}: { + message: MessageTypeModule.MessageType, + amqp: Amqp.Connection, + options?: {exchange: string | undefined} & Amqp.Options.Publish +}): Promise => { + return new Promise(async (resolve, reject) => { + const exchange = options?.exchange ?? 'undefined'; + if(!exchange) { + return reject('No exchange value found, please provide one') + } + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const routingKey = 'user/signedup'; +channel.publish(exchange, routingKey, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * AMQP publish operation for queue \`user/signedup\` + * + * @param message to publish + * @param amqp the AMQP connection to send over + * @param options for the AMQP publish queue operation + */ +publishToUserSignedupQueue: ({ + message, + amqp, + options +}: { + message: MessageTypeModule.MessageType, + amqp: Amqp.Connection, + options?: Amqp.Options.Publish +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +const channel = await amqp.createChannel(); +const queue = 'user/signedup'; +channel.sendToQueue(queue, Buffer.from(dataToSend), options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * AMQP subscribe operation for queue \`user/signedup\` + * + * @param {subscribeToUserSignedupQueueCallback} onDataCallback to call when messages are received + * @param amqp the AMQP connection to receive from + * @param options for the AMQP subscribe queue operation + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +subscribeToUserSignedupQueue: ({ + onDataCallback, + amqp, + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, amqpMsg?: Amqp.ConsumeMessage) => void, + amqp: Amqp.Connection, + options?: Amqp.Options.Consume, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + const channel = await amqp.createChannel(); +const queue = 'user/signedup'; +await channel.assertQueue(queue, { durable: true }); + +channel.consume(queue, (msg) => { + if (msg !== null) { + const receivedData = msg.content.toString() + + const message = MessageTypeModule.unmarshal(receivedData); + onDataCallback(undefined, message, msg); + } +}, options); + resolve(channel); + } catch (e: any) { + reject(e); + } + }); +} +}, +mqtt: { + /** + * MQTT publish operation for \`user/signedup\` + * + * @param message to publish + * @param mqtt the MQTT client to publish from + */ +publishToUserSignedup: ({ + message, + mqtt +}: { + message: MessageTypeModule.MessageType, + mqtt: Mqtt.MqttClient +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +mqtt.publish('user/signedup', dataToSend); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}, +kafka: { + /** + * Kafka publish operation for \`user.signedup\` + * + * @param message to publish + * @param kafka the KafkaJS client to publish from + */ +produceToUserSignedup: ({ + message, + kafka +}: { + message: MessageTypeModule.MessageType, + kafka: Kafka.Kafka +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); + const producer = kafka.producer(); + await producer.connect(); + await producer.send({ + topic: 'user.signedup', + messages: [ + { value: dataToSend }, + ], + }); + resolve(producer); + } catch (e: any) { + reject(e); + } + }); +}, +/** + * Callback for when receiving messages + * + * @callback consumeFromUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param kafkaMsg + */ + +/** + * Kafka subscription for \`user.signedup\` + * + * @param {consumeFromUserSignedupCallback} onDataCallback to call when messages are received + * @param kafka the KafkaJS client to subscribe through + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +consumeFromUserSignedup: ({ + onDataCallback, + kafka, + options = {fromBeginning: true, groupId: ''}, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: MessageTypeModule.MessageType, kafkaMsg?: Kafka.EachMessagePayload) => void, + kafka: Kafka.Kafka, + options: {fromBeginning: boolean, groupId: string}, + skipMessageValidation?: boolean +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + if(!options.groupId) { + return reject('No group ID provided'); + } + const consumer = kafka.consumer({ groupId: options.groupId }); + + + await consumer.connect(); + await consumer.subscribe({ topic: 'user.signedup', fromBeginning: options.fromBeginning }); + await consumer.run({ + eachMessage: async (kafkaMessage: Kafka.EachMessagePayload) => { + const { topic, message } = kafkaMessage; + const receivedData = message.value?.toString()!; + + +const callbackData = MessageTypeModule.unmarshal(receivedData); +onDataCallback(undefined, callbackData, kafkaMessage); + } + }); + resolve(consumer); + } catch (e: any) { + reject(e); + } + }); +} +}, +event_source: { + /** + * Event source fetch for \`user/signedup\` + * + * @param callback to call when receiving events + * @param options additionally used to handle the event source + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +listenForUserSignedup: async ({ + callback, + options, + skipMessageValidation = false +}: { + callback: (error?: Error, messageEvent?: MessageTypeModule.MessageType) => void, + options: {authorization?: string, onClose?: (err?: string) => void, baseUrl: string, headers?: Record}, + skipMessageValidation?: boolean +}) => { + let eventsUrl: string = 'user/signedup'; + const url = \`\${options.baseUrl}/\${eventsUrl}\` + const headers: Record = { + ...options.headers ?? {}, + Accept: 'text/event-stream' + } + if(options.authorization) { + headers['authorization'] = \`Bearer \${options?.authorization}\`; + } + + await fetchEventSource(\`\${url}\`, { + method: 'GET', + headers, + onmessage: (ev: EventSourceMessage) => { + const receivedData = ev.data; + + const callbackData = MessageTypeModule.unmarshal(receivedData); + callback(undefined, callbackData); + }, + onerror: (err) => { + options.onClose?.(err); + }, + onclose: () => { + options.onClose?.(); + }, + async onopen(response: { ok: any; headers: any; status: number }) { + if (response.ok && response.headers.get('content-type') === EventStreamContentType) { + return // everything's good + } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { + // client-side errors are usually non-retriable: + callback(new Error('Client side error, could not open event connection')) + } else { + callback(new Error('Unknown error, could not open event connection')); + } + }, + }) +} +, +registerUserSignedup: ({ + router, + callback +}: { + router: Router, + callback: ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => void) | ((req: Request, res: Response, next: NextFunction, sendEvent: (message: MessageTypeModule.MessageType) => void) => Promise) +}) => { + const event = '/user/signedup'; + router.get(event, async (req, res, next) => { + + res.writeHead(200, { + 'Cache-Control': 'no-cache, no-transform', + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }) + const sendEventCallback = (message: MessageTypeModule.MessageType) => { + if (res.closed) { + return + } + res.write(\`event: \${event}\\n\`) + res.write(\`data: \${MessageTypeModule.marshal(message)}\\n\\n\`) + } + await callback(req, res, next, sendEventCallback) + }) +} + +}};" +`; + +exports[`channels typescript should work with operation extension 1`] = ` +"import * as TestPayloadModelModule from './../../../../TestPayloadModel'; +import * as Nats from 'nats'; +export const Protocols = { +nats: { + /** + * JetStream publish operation for \`user.signedup\` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +jetStreamPublishToUserSignedup: ({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: MessageTypeModule.MessageType, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise => { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = MessageTypeModule.marshal(message); +dataToSend = codec.encode(dataToSend); +await js.publish('user.signedup', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} +}};" +`; + +exports[`channels typescript should work with request and reply AsyncAPI 1`] = ` +"import * as TestPayloadModelModule from './../../../../TestPayloadModel'; +import { URLSearchParams } from 'url'; +import * as NodeFetch from 'node-fetch'; +export const Protocols = { +http_client: { + async getPingRequest(context: { + server?: string; + payload: undefined; + path?: string; + accessToken?: string; + credentials?: RequestCredentials; //value for the credentials param we want to use on each request + additionalHeaders?: Record; //header params we want to use on every request, + makeRequestCallback?: ({ + method, body, url, headers + }: { + url: string, + headers?: Record, + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD', + credentials?: RequestCredentials, + body?: any + }) => Promise<{ + ok: boolean, + status: number, + statusText: string, + json: () => Record | Promise>, + }> + }): Promise { + const parsedContext = { + ...{ + makeRequestCallback: async ({url, body, method, headers}) => { + return NodeFetch.default(url, { + body, + method, + headers + }) + }, + path: '/ping', + server: localhost:3000, + }, + ...context, + } + const headers = { + 'Content-Type': 'application/json', + ...parsedContext.additionalHeaders + }; + const url = \`\${parsedContext.server}\${parsedContext.path}\`; + + let body: any; + if (parsedContext.payload) { + body = parsedContext.payload.marshal(); + } + + if (parsedContext.accessToken) { + // oauth required + headers["Authorization"] = parsedContext.accessToken; + } + + const response = await parsedContext.makeRequestCallback({url, + method: 'GET', + headers, + body + }); + if (response.ok) { + const data = await response.json(); + return MessageTypeModule.MessageType.unmarshal(data); + } + return Promise.reject(new Error(response.statusText)); +} +}};" +`; diff --git a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap new file mode 100644 index 00000000..ae4e2a37 --- /dev/null +++ b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap @@ -0,0 +1,256 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`payloads typescript should not render validation functions 1`] = ` +" +type UnionPayload = Record; +export { UnionPayload };" +`; + +exports[`payloads typescript should not render validation functions 2`] = ` +" +class SimpleObject2 { + private _displayName?: string; + private _email?: string; + private _additionalProperties?: Record; + + constructor(input: { + displayName?: string, + email?: string, + additionalProperties?: Record, + }) { + this._displayName = input.displayName; + this._email = input.email; + this._additionalProperties = input.additionalProperties; + } + + get displayName(): string | undefined { return this._displayName; } + set displayName(displayName: string | undefined) { this._displayName = displayName; } + + get email(): string | undefined { return this._email; } + set email(email: string | undefined) { this._email = email; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public marshal() : string { + let json = '{' + if(this.displayName !== undefined) { + json += \`"displayName": \${typeof this.displayName === 'number' || typeof this.displayName === 'boolean' ? this.displayName : JSON.stringify(this.displayName)},\`; + } + if(this.email !== undefined) { + json += \`"email": \${typeof this.email === 'number' || typeof this.email === 'boolean' ? this.email : JSON.stringify(this.email)},\`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["displayName","email","additionalProperties"].includes(String(key))) continue; + json += \`"\${key}": \${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},\`; + } + } + //Remove potential last comma + return \`\${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}\`; + } + + public static unmarshal(json: string | object): SimpleObject2 { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new SimpleObject2({} as any); + + if (obj["displayName"] !== undefined) { + instance.displayName = obj["displayName"]; + } + if (obj["email"] !== undefined) { + instance.email = obj["email"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["displayName","email","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } +} +export { SimpleObject2 };" +`; + +exports[`payloads typescript should work with basic AsyncAPI inputs 1`] = ` +" +type UnionPayload = Record; +export { UnionPayload };" +`; + +exports[`payloads typescript should work with basic AsyncAPI inputs 2`] = ` +"import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormats from 'ajv-formats'; +class SimpleObject2 { + private _displayName?: string; + private _email?: string; + private _additionalProperties?: Record; + + constructor(input: { + displayName?: string, + email?: string, + additionalProperties?: Record, + }) { + this._displayName = input.displayName; + this._email = input.email; + this._additionalProperties = input.additionalProperties; + } + + get displayName(): string | undefined { return this._displayName; } + set displayName(displayName: string | undefined) { this._displayName = displayName; } + + get email(): string | undefined { return this._email; } + set email(email: string | undefined) { this._email = email; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public marshal() : string { + let json = '{' + if(this.displayName !== undefined) { + json += \`"displayName": \${typeof this.displayName === 'number' || typeof this.displayName === 'boolean' ? this.displayName : JSON.stringify(this.displayName)},\`; + } + if(this.email !== undefined) { + json += \`"email": \${typeof this.email === 'number' || typeof this.email === 'boolean' ? this.email : JSON.stringify(this.email)},\`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["displayName","email","additionalProperties"].includes(String(key))) continue; + json += \`"\${key}": \${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},\`; + } + } + //Remove potential last comma + return \`\${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}\`; + } + + public static unmarshal(json: string | object): SimpleObject2 { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new SimpleObject2({} as any); + + if (obj["displayName"] !== undefined) { + instance.displayName = obj["displayName"]; + } + if (obj["email"] !== undefined) { + instance.email = obj["email"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["displayName","email","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","properties":{"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"SimpleObject2"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { SimpleObject2 };" +`; + +exports[`payloads typescript should work with no channels 1`] = ` +"import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormats from 'ajv-formats'; +class AnonymousSchema_1 { + private _type?: 'SimpleObject' = 'SimpleObject'; + private _displayName?: string; + private _email?: string; + private _additionalProperties?: Record; + + constructor(input: { + displayName?: string, + email?: string, + additionalProperties?: Record, + }) { + this._displayName = input.displayName; + this._email = input.email; + this._additionalProperties = input.additionalProperties; + } + + get type(): 'SimpleObject' | undefined { return this._type; } + + get displayName(): string | undefined { return this._displayName; } + set displayName(displayName: string | undefined) { this._displayName = displayName; } + + get email(): string | undefined { return this._email; } + set email(email: string | undefined) { this._email = email; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public marshal() : string { + let json = '{' + if(this.type !== undefined) { + json += \`"type": \${typeof this.type === 'number' || typeof this.type === 'boolean' ? this.type : JSON.stringify(this.type)},\`; + } + if(this.displayName !== undefined) { + json += \`"displayName": \${typeof this.displayName === 'number' || typeof this.displayName === 'boolean' ? this.displayName : JSON.stringify(this.displayName)},\`; + } + if(this.email !== undefined) { + json += \`"email": \${typeof this.email === 'number' || typeof this.email === 'boolean' ? this.email : JSON.stringify(this.email)},\`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["type","displayName","email","additionalProperties"].includes(String(key))) continue; + json += \`"\${key}": \${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},\`; + } + } + //Remove potential last comma + return \`\${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}\`; + } + + public static unmarshal(json: string | object): AnonymousSchema_1 { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new AnonymousSchema_1({} as any); + + if (obj["displayName"] !== undefined) { + instance.displayName = obj["displayName"]; + } + if (obj["email"] !== undefined) { + instance.email = obj["email"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["type","displayName","email","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + public static theCodeGenSchema = {"type":"object","properties":{"type":{"const":"SimpleObject"},"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { AnonymousSchema_1 };" +`; diff --git a/test/runtime/asyncapi-http.json b/test/runtime/asyncapi-http.json new file mode 100644 index 00000000..a7bdecf7 --- /dev/null +++ b/test/runtime/asyncapi-http.json @@ -0,0 +1,119 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "Runtime testing example", + "version": "1.0.0" + }, + "channels": { + "ping": { + "address": "/ping", + "messages": { + "ping": { + "$ref": "#/components/messages/ping" + }, + "pong": { + "$ref": "#/components/messages/pong" + } + } + } + }, + "operations": { + "pingPostRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/ping"} + ], + "bindings": { + "http": { + "method": "POST" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + } + }, + "pingGetRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ ], + "bindings": { + "http": { + "method": "GET" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + } + }, + }, + "components": { + "messages": { + "UserSignedUp": { + "payload": { + "$ref": "#/components/schemas/UserSignedUpPayload" + }, + "headers": { + "$ref": "#/components/schemas/UserSignedUpPayload" + } + }, + "ping": { + "payload": { + "type": "object", + "properties": { + "ping": { + "type": "string", + "description": "ping name" + } + } + } + }, + "pong": { + "payload": { + "type": "object", + "properties": { + "pong": { + "type": "string", + "description": "pong name" + } + } + }, + "bindings": { + "http": { + "statusCode": "200" + } + } + } + }, + "schemas": { + "UserSignedUpPayload": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "description": "Name of the user" + }, + "email": { + "type": "string", + "format": "email", + "description": "Email of the user" + } + } + } + } + } +} \ No newline at end of file diff --git a/test/runtime/asyncapi.json b/test/runtime/asyncapi.json index 4d3a72b7..82b77324 100644 --- a/test/runtime/asyncapi.json +++ b/test/runtime/asyncapi.json @@ -65,7 +65,7 @@ } ] }, - "pingRequest": { + "pingGetRequest": { "action": "send", "channel": { "$ref": "#/channels/ping" @@ -75,7 +75,7 @@ ], "bindings": { "http": { - "method": "GET" + "method": "POST" } }, "reply": { diff --git a/test/runtime/typescript/package-lock.json b/test/runtime/typescript/package-lock.json index 0d93bfc5..8c16bd31 100644 --- a/test/runtime/typescript/package-lock.json +++ b/test/runtime/typescript/package-lock.json @@ -20,8 +20,8 @@ }, "devDependencies": { "@types/amqplib": "^0.10.6", - "@types/node-fetch": "^2.6.12" "@types/express": "^4.17.21", + "@types/node-fetch": "^2.6.12", "jest-fetch-mock": "^3.0.3" } }, @@ -1181,13 +1181,14 @@ } }, "node_modules/@types/node-fetch/node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" }, "engines": { @@ -2198,6 +2199,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -2701,6 +2717,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", diff --git a/test/runtime/typescript/test/http_client.spec.ts b/test/runtime/typescript/test/channels/http_client.spec.ts similarity index 68% rename from test/runtime/typescript/test/http_client.spec.ts rename to test/runtime/typescript/test/channels/http_client.spec.ts index 61a81335..14a656b5 100644 --- a/test/runtime/typescript/test/http_client.spec.ts +++ b/test/runtime/typescript/test/channels/http_client.spec.ts @@ -1,10 +1,10 @@ /* eslint-disable no-console */ -import { Protocols } from '../src/request-reply/channels/index'; -import { Ping } from "../src/request-reply/payloads/Ping"; -import { Pong } from "../src/request-reply/payloads/Pong"; +import { Protocols } from '../../src/request-reply/channels/index'; +import { Ping } from "../../src/request-reply/payloads/Ping"; +import { Pong } from "../../src/request-reply/payloads/Pong"; import express, { Router } from 'express'; const {http_client } = Protocols; -const {getPingRequest } = http_client; +const {postPingGetRequest } = http_client; jest.setTimeout(10000); describe('http_fetch', () => { @@ -14,7 +14,7 @@ describe('http_fetch', () => { afterEach(() => { server?.close(); }); - it('should be able to make GET request', async () => { + it('should be able to make POST request', async () => { return new Promise(async (resolve, reject) => { const router = Router(); const app = express(); @@ -25,11 +25,12 @@ describe('http_fetch', () => { const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); router.post('/ping', (req, res) => { + res.setHeader('Content-Type', 'application/json'); res.write(replyMessage.marshal()); res.end(); }); server = app.listen(portToUse, async () => { - const receivedReplyMessage = await getPingRequest({payload: requestMessage, basePath: `http://localhost:${ portToUse}`}); + const receivedReplyMessage = await postPingGetRequest({payload: requestMessage, server: `http://localhost:${portToUse}`}); expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); resolve(); }); From d3d051fea6eb00222363731984a207f43478de02 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 17 May 2025 13:01:09 +0200 Subject: [PATCH 11/19] add username/password auth support --- docs/protocols/http.md | 2 +- .../generators/typescript/channels/protocols/http/fetch.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/protocols/http.md b/docs/protocols/http.md index e26e8c61..a8f52338 100644 --- a/docs/protocols/http.md +++ b/docs/protocols/http.md @@ -26,7 +26,7 @@ All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP | OAuth2 Implicit | ➗ | ➗ | | OAuth2 password | ➗ | ➗ | | OAuth2 Client Credentials | ➗ | ➗ | -| Username/password Authentication | ➗ | ➗ | +| Username/password Authentication | ✔️ | ➗ | | Bearer Authentication | ➗ | ➗ | | Basic Authentication | ➗ | ➗ | | API Key Authentication | ➗ | ➗ | diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index c614ce2d..6bc32a3f 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -34,6 +34,8 @@ export function renderHttpFetchClient({ ${messageType ?? `payload: ${messageType};`} path?: string; accessToken?: string; + username?: string; + password?: string; credentials?: RequestCredentials; //value for the credentials param we want to use on each request additionalHeaders?: Record; //header params we want to use on every request, makeRequestCallback?: ({ @@ -79,6 +81,10 @@ export function renderHttpFetchClient({ if (parsedContext.accessToken) { // oauth required headers["Authorization"] = parsedContext.accessToken; + } else if (parsedContext.username && parsedContext.password) { + // basic authentication + const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64'); + headers["Authorization"] = \`Basic \${credentials}\`; } const response = await parsedContext.makeRequestCallback({url, From 06e02b809dce58f1f6c40fd9bf2df0e164fc07de Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Fri, 23 May 2025 21:14:56 +0200 Subject: [PATCH 12/19] fix client --- .vscode/launch.json | 19 + docs/protocols/http.md | 14 +- src/codegen/generators/helpers/payloads.ts | 11 +- .../channels/protocols/amqp/index.ts | 10 + .../channels/protocols/eventsource/index.ts | 12 +- .../channels/protocols/http/fetch.ts | 335 +++++++++++- .../channels/protocols/http/index.ts | 13 +- .../channels/protocols/kafka/index.ts | 12 +- .../channels/protocols/mqtt/index.ts | 10 +- .../channels/protocols/nats/index.ts | 78 ++- .../generators/typescript/channels/types.ts | 4 +- .../generators/typescript/channels/utils.ts | 11 +- src/codegen/generators/typescript/payloads.ts | 54 +- .../__snapshots__/channels.spec.ts.snap | 334 +++++++++++- .../__snapshots__/payload.spec.ts.snap | 44 +- test/runtime/asyncapi-http.json | 119 ---- test/runtime/asyncapi.json | 163 ------ .../typescript/codegen-request-reply.mjs | 2 +- test/runtime/typescript/codegen.mjs | 36 -- test/runtime/typescript/package.json | 14 +- .../typescript/test/channels/amqp.spec.ts | 126 ----- .../test/channels/eventsource.spec.ts | 115 ---- .../test/channels/http_client.spec.ts | 40 -- .../typescript/test/channels/kafka.spec.ts | 131 ----- .../typescript/test/channels/mqtt.spec.ts | 48 -- .../typescript/test/channels/nats.spec.ts | 514 ------------------ 26 files changed, 882 insertions(+), 1387 deletions(-) delete mode 100644 test/runtime/asyncapi-http.json delete mode 100644 test/runtime/asyncapi.json delete mode 100644 test/runtime/typescript/codegen.mjs delete mode 100644 test/runtime/typescript/test/channels/amqp.spec.ts delete mode 100644 test/runtime/typescript/test/channels/eventsource.spec.ts delete mode 100644 test/runtime/typescript/test/channels/http_client.spec.ts delete mode 100644 test/runtime/typescript/test/channels/kafka.spec.ts delete mode 100644 test/runtime/typescript/test/channels/mqtt.spec.ts delete mode 100644 test/runtime/typescript/test/channels/nats.spec.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 8ad6618c..ca682e98 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,6 +24,25 @@ "windows": { "program": "${workspaceFolder}/node_modules/jest/bin/jest" } + }, + { + "type": "node", + "request": "launch", + "name": "Debug runtime generation", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/bin/run.mjs", + "args": ["generate", "${input:codegenFile}"], + "cwd": "${workspaceFolder}/test/runtime/typescript", + "console": "integratedTerminal" + } + ], + "inputs": [ + { + "id": "codegenFile", + "type": "pickString", + "description": "Select codegen file to use", + "options": ["./codegen-regular.mjs", "./codegen-request-reply.mjs"], + "default": "./codegen-regular.mjs" } ] } \ No newline at end of file diff --git a/docs/protocols/http.md b/docs/protocols/http.md index a8f52338..198580e9 100644 --- a/docs/protocols/http.md +++ b/docs/protocols/http.md @@ -22,14 +22,14 @@ All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP | Time based Pagination | ➗ | ➗ | | Keyset based Pagination | ➗ | ➗ | | Retry with backoff | ➗ | ➗ | -| OAuth2 Authorization code | ➗ | ➗ | -| OAuth2 Implicit | ➗ | ➗ | -| OAuth2 password | ➗ | ➗ | -| OAuth2 Client Credentials | ➗ | ➗ | +| OAuth2 Authorization code | ✔️ | ➗ | +| OAuth2 Implicit | ✔️ | ➗ | +| OAuth2 password | ✔️ | ➗ | +| OAuth2 Client Credentials | ✔️ | ➗ | | Username/password Authentication | ✔️ | ➗ | -| Bearer Authentication | ➗ | ➗ | -| Basic Authentication | ➗ | ➗ | -| API Key Authentication | ➗ | ➗ | +| Bearer Authentication | ✔️ | ➗ | +| Basic Authentication | ✔️ | ➗ | +| API Key Authentication | ✔️ | ➗ | | XML Based API | ➗ | ➗ | | JSON Based API | ✔️ | ➗ | | POST | ✔️ | ➗ | diff --git a/src/codegen/generators/helpers/payloads.ts b/src/codegen/generators/helpers/payloads.ts index 6537f1e6..50e86d3c 100644 --- a/src/codegen/generators/helpers/payloads.ts +++ b/src/codegen/generators/helpers/payloads.ts @@ -38,16 +38,24 @@ export async function generateAsyncAPIPayloads( schemaObj.oneOf = []; schemaObj['$id'] = pascalCase(`${preId}_Payload`); for (const message of messages) { - if (message.hasPayload()) { + if (!message.hasPayload()) { break; } const schema = AsyncAPIInputProcessor.convertToInternalSchema( message.payload() as any ); + const payloadId = message.id() ?? message.name(); if (typeof schema === 'boolean') { schemaObj.oneOf.push(schema); } else { + const bindings = message.bindings(); + const statusCodesBindings = bindings?.get('http'); + const statusCodes = statusCodesBindings?.json()['statusCode']; + if (statusCodesBindings && statusCodes) { + schemaObj['x-modelina-has-status-codes'] = true; + schema['x-modelina-status-codes'] = statusCodes; + } schemaObj.oneOf.push({ ...schema, $id: payloadId @@ -89,7 +97,6 @@ export async function generateAsyncAPIPayloads( return {generatedMessages: models, messageType}; }; for (const operation of channel.operations().all()) { - //const operationMessages = operation.messages().all().filter((message) => message.id() !== undefined); const operationMessages = operation.messages().all(); const operationReply = operation.reply(); if (operationReply) { diff --git a/src/codegen/generators/typescript/channels/protocols/amqp/index.ts b/src/codegen/generators/typescript/channels/protocols/amqp/index.ts index 1cdb4e5c..bd2c9120 100644 --- a/src/codegen/generators/typescript/channels/protocols/amqp/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/amqp/index.ts @@ -105,6 +105,11 @@ async function generateForOperations( } const {messageModule, messageType} = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error( + `Could not find message type for channel typescript generator for AMQP` + ); + } const updatedContext = { ...amqpContext, messageType, @@ -185,6 +190,11 @@ async function generateForChannels( } const {messageModule, messageType} = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error( + `Could not find message type for channel typescript generator for AMQP` + ); + } const updatedContext = {...amqpContext, messageType, messageModule}; const renderChecks = [ diff --git a/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts b/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts index 310b5fa4..d0cb1f08 100644 --- a/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts @@ -100,11 +100,16 @@ async function generateForOperations( const payload = payloads.operationModels[payloadId]; if (!payload) { throw new Error( - `Could not find payload for operation in channel typescript generator` + `Could not find payload for operation in channel typescript generator for EventSource` ); } const {messageModule, messageType} = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error( + `Could not find message type for channel typescript generator for EventSource` + ); + } const updatedContext = { ...eventSourceContext, messageType, @@ -181,6 +186,11 @@ async function generateForChannels( } const {messageModule, messageType} = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error( + `Could not find message type for channel typescript generator for EventSource` + ); + } const updatedContext = {...eventSourceContext, messageType, messageModule}; const renderChecks = [ diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index 6bc32a3f..ba92ba6d 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -24,18 +24,40 @@ export function renderHttpFetchClient({ const replyType = replyMessageModule ? `${replyMessageModule}.${replyMessageType}` : replyMessageType; - const statusCodeChecks = statusCodes.filter((value) => value.code < 200 && value.code >= 300).map((value) => { - return `else if (response.status === ${value.code}) { - return Promise.reject(new FetchError(new Error(response.statusText), response.status, '${value.description}')); -}`; - }); const code = `async ${functionName}(context: { server?: ${[...servers.map((value) => `'${value}'`), 'string'].join(' | ')}; - ${messageType ?? `payload: ${messageType};`} + ${messageType ? `payload: ${messageType};` : ''} path?: string; - accessToken?: string; + bearerToken?: string; username?: string; password?: string; + apiKey?: string; // API key value + apiKeyName?: string; // Name of the API key parameter + apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header) + // OAuth2 parameters + oauth2?: { + clientId: string; + clientSecret?: string; + accessToken?: string; + refreshToken?: string; + tokenUrl?: string; + authorizationUrl?: string; + redirectUri?: string; + scopes?: string[]; + flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter + // For password flow + username?: string; // Username for password flow + password?: string; // Password for password flow + onTokenRefresh?: (newTokens: { + accessToken: string; + refreshToken?: string; + expiresIn?: number; + }) => void; + // For Implicit flow + responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow + state?: string; // For security against CSRF + onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow + }; credentials?: RequestCredentials; //value for the credentials param we want to use on each request additionalHeaders?: Record; //header params we want to use on every request, makeRequestCallback?: ({ @@ -63,47 +85,316 @@ export function renderHttpFetchClient({ }) }, path: ${addressToUse}, - server: ${servers[0] ?? 'localhost:3000'}, + server: ${servers[0] ?? '\'localhost:3000\''}, + apiKeyIn: 'header', + apiKeyName: 'X-API-Key', }, ...context, } + + // Validate parameters before proceeding with the request + // OAuth2 Implicit flow validation + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') { + if (!parsedContext.oauth2.authorizationUrl) { + return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Implicit flow requires clientId')); + } + if (!parsedContext.oauth2.redirectUri) { + return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri')); + } + if (!parsedContext.oauth2.onImplicitRedirect) { + return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler')); + } + } + + // OAuth2 Client Credentials flow validation + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') { + if (!parsedContext.oauth2.tokenUrl) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId')); + } + } + + // OAuth2 Password flow validation + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') { + if (!parsedContext.oauth2.tokenUrl) { + return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Password flow requires clientId')); + } + if (!parsedContext.oauth2.username) { + return Promise.reject(new Error('OAuth2 Password flow requires username')); + } + if (!parsedContext.oauth2.password) { + return Promise.reject(new Error('OAuth2 Password flow requires password')); + } + } + const headers = { - 'Content-Type': 'application/json', - ...parsedContext.additionalHeaders + 'Content-Type': 'application/json', + ...parsedContext.additionalHeaders }; - const url = \`\${parsedContext.server}\${parsedContext.path}\`; + let url = \`\${parsedContext.server}\${parsedContext.path}\`; let body: any; - ${messageType ?? `if (parsedContext.payload) { + ${messageType ? `if (parsedContext.payload) { body = parsedContext.payload.marshal(); - }`} + }` : ''} - if (parsedContext.accessToken) { - // oauth required - headers["Authorization"] = parsedContext.accessToken; + // Handle different authentication methods + if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) { + // OAuth2 authentication with existing access token + headers["Authorization"] = \`Bearer \${parsedContext.oauth2.accessToken}\`; + } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) { + // Build the authorization URL for implicit flow + const authUrl = new URL(parsedContext.oauth2.authorizationUrl); + authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId); + authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!); + authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token'); + + if (parsedContext.oauth2.state) { + authUrl.searchParams.append('state', parsedContext.oauth2.state); + } + + if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) { + authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' ')); + } + + // Call the redirect handler + parsedContext.oauth2.onImplicitRedirect(authUrl.toString()); + // Since we've initiated a redirect flow, we can't continue with the request + // The application will need to handle the redirect and subsequent token extraction + return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated')); + } else if (parsedContext.bearerToken) { + // bearer authentication + headers["Authorization"] = \`Bearer \${parsedContext.bearerToken}\`; } else if (parsedContext.username && parsedContext.password) { // basic authentication const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64'); headers["Authorization"] = \`Basic \${credentials}\`; } + + // API Key Authentication + if (parsedContext.apiKey) { + if (parsedContext.apiKeyIn === 'header') { + // Add API key to headers + headers[parsedContext.apiKeyName] = parsedContext.apiKey; + } else if (parsedContext.apiKeyIn === 'query') { + // Add API key to query parameters + const separator = url.includes('?') ? '&' : '?'; + url = \`\${url}\${separator}\${parsedContext.apiKeyName}=\${encodeURIComponent(parsedContext.apiKey)}\`; + } + } + // Make the API request const response = await parsedContext.makeRequestCallback({url, method: '${method}', headers, body }); - if (response.ok) { - const data = await response.json(); - return ${replyType}.unmarshal(data); - } ${statusCodeChecks.join('\n ')} - return Promise.reject(new Error(response.statusText)); + + // Handle OAuth2 Client Credentials flow + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) { + try { + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: parsedContext.oauth2.clientId + }); + + if (parsedContext.oauth2.clientSecret) { + params.append('client_secret', parsedContext.oauth2.clientSecret); + } + + if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) { + params.append('scope', parsedContext.oauth2.scopes.join(' ')); + } + + // Some APIs use basic auth with client credentials instead of form params + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + // If both client ID and secret are provided, some servers prefer basic auth + if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) { + const credentials = Buffer.from( + \`\${parsedContext.oauth2.clientId}:\${parsedContext.oauth2.clientSecret}\` + ).toString('base64'); + authHeaders['Authorization'] = \`Basic \${credentials}\`; + // Remove client_id and client_secret from the request body when using basic auth + params.delete('client_id'); + params.delete('client_secret'); + } + + const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, { + method: 'POST', + headers: authHeaders, + body: params.toString() + }); + + if (tokenResponse.ok) { + const tokenData = await tokenResponse.json(); + const tokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Update headers with the new token + headers["Authorization"] = \`Bearer \${tokens.accessToken}\`; + + // Notify the client about the tokens + if (parsedContext.oauth2.onTokenRefresh) { + parsedContext.oauth2.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const retryResponse = await parsedContext.makeRequestCallback({ + url, + method: "${method}", + headers, + body + }); + + const data = await retryResponse.json(); + return ${replyMessageModule ? `${replyMessageModule}.unmarshalByStatusCode(data, retryResponse.status)` : `${replyMessageType}.unmarshal(data)`}; + } else { + return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`)); + } + } catch (error) { + console.error('Error in OAuth2 Client Credentials flow:', error); + return Promise.reject(error); + } + } + + // Handle OAuth2 password flow + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) { + try { + const params = new URLSearchParams({ + grant_type: 'password', + username: parsedContext.oauth2.username || '', + password: parsedContext.oauth2.password || '', + client_id: parsedContext.oauth2.clientId, + }); + + if (parsedContext.oauth2.clientSecret) { + params.append('client_secret', parsedContext.oauth2.clientSecret); + } + + if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) { + params.append('scope', parsedContext.oauth2.scopes.join(' ')); + } + + const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (tokenResponse.ok) { + const tokenData = await tokenResponse.json(); + const tokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Update headers with the new token + headers["Authorization"] = \`Bearer \${tokens.accessToken}\`; + + // Notify the client about the tokens + if (parsedContext.oauth2.onTokenRefresh) { + parsedContext.oauth2.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const retryResponse = await parsedContext.makeRequestCallback({ + url, + method: "${method}", + headers, + body + }); + + const data = await retryResponse.json(); + return ${replyMessageModule ? `${replyMessageModule}.unmarshalByStatusCode(data, retryResponse.status)` : `${replyMessageType}.unmarshal(data)`}; + + } else { + return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`)); + } + } catch (error) { + console.error('Error in OAuth2 password flow:', error); + return Promise.reject(error); + } + } + + // Handle token refresh for OAuth2 if we get a 401 + if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) { + try { + const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: parsedContext.oauth2.refreshToken, + client_id: parsedContext.oauth2.clientId, + ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {}) + }).toString() + }); + + if (refreshResponse.ok) { + const tokenData = await refreshResponse.json(); + const newTokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken, + expiresIn: tokenData.expires_in + }; + + // Update the access token for this request + headers["Authorization"] = \`Bearer \${newTokens.accessToken}\`; + + // Notify the client about the refreshed tokens + if (parsedContext.oauth2.onTokenRefresh) { + parsedContext.oauth2.onTokenRefresh(newTokens); + } + + // Retry the original request with the new token + const retryResponse = await parsedContext.makeRequestCallback({ + url, + method: "${method}", + headers, + body + }); + + const data = await retryResponse.json(); + return ${replyMessageModule ? `${replyMessageModule}.unmarshalByStatusCode(data, retryResponse.status)` : `${replyMessageType}.unmarshal(data)`}; + } else { + // Token refresh failed, return a standardized error message + return Promise.reject(new Error('Unauthorized')); + } + } catch (error) { + console.error('Error refreshing token:', error); + // For any error during refresh, return a standardized error message + return Promise.reject(new Error('Unauthorized')); + } + } + + const data = await response.json(); + return ${replyMessageModule ? `${replyMessageModule}.unmarshalByStatusCode(data, response.status)` : `${replyMessageType}.unmarshal(data)`}; }`; return { messageType, replyType, code, functionName, - dependencies: [`import { URLSearchParams } from 'url';`, `import * as NodeFetch from 'node-fetch';`], + dependencies: [`import { URLSearchParams, URL } from 'url';`, `import * as NodeFetch from 'node-fetch';`], functionType: ChannelFunctionTypes.HTTP_CLIENT }; } diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts index cb980ff9..1efe99c4 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -97,9 +97,11 @@ function generateForOperations( generator.asyncapiReverseOperations ) ) { + const httpMethod = + operation.bindings().get('http')?.json()['method'] ?? 'GET'; const payloadId = findOperationId(operation, channel); const payload = payloads.operationModels[payloadId]; - if (payload === undefined) { + if (payload === undefined && httpMethod === 'POST') { throw new Error( `Could not find payload for ${payloadId} for channel typescript generator ${JSON.stringify(payloads.operationModels, null, 4)}` ); @@ -111,7 +113,7 @@ function generateForOperations( const replyMessageModel = payloads.operationModels[replyId]; if (!replyMessageModel) { throw new Error( - `Could not find payload for reply ${replyId} for channel typescript generator` + `Could not find payload for reply ${replyId} for channel typescript generator for HTTP` ); } const statusCodes = operation @@ -133,8 +135,11 @@ function generateForOperations( messageModule: replyMessageModule, messageType: replyMessageType } = getMessageTypeAndModule(replyMessageModel); - const httpMethod = - operation.bindings().get('http')?.json()['method'] ?? 'GET'; + if (replyMessageType === undefined) { + throw new Error( + `Could not find reply message type for channel typescript generator for HTTP` + ); + } renders.push( renderHttpFetchClient({ subName: findNameFromOperation(operation, channel), diff --git a/src/codegen/generators/typescript/channels/protocols/kafka/index.ts b/src/codegen/generators/typescript/channels/protocols/kafka/index.ts index eafc7238..a958656f 100644 --- a/src/codegen/generators/typescript/channels/protocols/kafka/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/kafka/index.ts @@ -100,11 +100,16 @@ async function generateForOperations( const payload = payloads.operationModels[payloadId]; if (!payload) { throw new Error( - `Could not find payload for operation in channel typescript generator` + `Could not find payload for operation in channel typescript generator for Kafka` ); } const {messageModule, messageType} = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error( + `Could not find message type for channel typescript generator for Kafka` + ); + } const updatedContext = { ...kafkaContext, messageType, @@ -174,6 +179,11 @@ async function generateForChannels( } const {messageModule, messageType} = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error( + `Could not find message type for channel typescript generator for Kafka` + ); + } const updatedContext = {...kafkaContext, messageType, messageModule}; const renderChecks = [ diff --git a/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts b/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts index f9448a4f..8068300d 100644 --- a/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts @@ -101,6 +101,9 @@ function generateForOperations( ); } const {messageModule, messageType} = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error(`Could not find message type for ${payloadId} for mqtt channel typescript generator`); + } const updatedContext = { ...mqttContext, messageType, @@ -137,10 +140,13 @@ function generateForChannels( const payload = payloads.channelModels[channel.id()]; if (payload === undefined) { throw new Error( - `Could not find payload for ${channel.id()} for channel typescript generator` + `Could not find payload for ${channel.id()} for mqtt channel typescript generator` ); - } + } const {messageModule, messageType} = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error(`Could not find message type for ${channel.id()} for mqtt channel typescript generator`); + } const updatedContext = {...mqttContext, messageType, messageModule}; if ( shouldRenderFunctionType( diff --git a/src/codegen/generators/typescript/channels/protocols/nats/index.ts b/src/codegen/generators/typescript/channels/protocols/nats/index.ts index e766df58..3c03ab91 100644 --- a/src/codegen/generators/typescript/channels/protocols/nats/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/nats/index.ts @@ -11,22 +11,22 @@ import { findOperationId, findReplyId } from '../../../../../utils'; -import {getMessageTypeAndModule} from '../../utils'; +import { getMessageTypeAndModule } from '../../utils'; import { getFunctionTypeMappingFromAsyncAPI, shouldRenderFunctionType } from '../../asyncapi'; -import {renderCoreRequest} from './coreRequest'; -import {renderCoreReply} from './coreReply'; -import {renderCorePublish} from './corePublish'; -import {renderCoreSubscribe} from './coreSubscribe'; -import {renderJetstreamPullSubscribe} from './jetstreamPullSubscribe'; -import {renderJetstreamPushSubscription} from './jetstreamPushSubscription'; -import {renderJetstreamPublish} from './jetstreamPublish'; -import {ChannelInterface, OperationInterface} from '@asyncapi/parser'; -import {SingleFunctionRenderType} from '../../../../../types'; -import {ConstrainedObjectModel} from '@asyncapi/modelina'; -import {TypeScriptPayloadRenderType} from '../../../payloads'; +import { renderCoreRequest } from './coreRequest'; +import { renderCoreReply } from './coreReply'; +import { renderCorePublish } from './corePublish'; +import { renderCoreSubscribe } from './coreSubscribe'; +import { renderJetstreamPullSubscribe } from './jetstreamPullSubscribe'; +import { renderJetstreamPushSubscription } from './jetstreamPushSubscription'; +import { renderJetstreamPublish } from './jetstreamPublish'; +import { ChannelInterface, OperationInterface } from '@asyncapi/parser'; +import { SingleFunctionRenderType } from '../../../../../types'; +import { ConstrainedObjectModel } from '@asyncapi/modelina'; +import { TypeScriptPayloadRenderType } from '../../../payloads'; export { renderCoreRequest, @@ -48,7 +48,7 @@ export async function generateNatsChannels( >, dependencies: string[] ) { - const {parameter, topic, payloads} = context; + const { parameter, topic, payloads } = context; const ignoreOperation = !context.generator.asyncapiGenerateForOperations; let natsTopic = topic.startsWith('/') ? topic.slice(1) : topic; natsTopic = natsTopic.replace(/\//g, '.'); @@ -108,21 +108,41 @@ async function generateForOperations( natsContext: RenderRegularParameters ): Promise { const renders: SingleFunctionRenderType[] = []; - const {generator, payloads} = context; + const { generator, payloads } = context; const functionTypeMapping = generator.functionTypeMapping[channel.id()]; for (const operation of channel.operations().all()) { const updatedFunctionTypeMapping = getFunctionTypeMappingFromAsyncAPI(operation) ?? functionTypeMapping; + if ( + updatedFunctionTypeMapping !== undefined && !updatedFunctionTypeMapping?.some((f) => + [ + ChannelFunctionTypes.NATS_REQUEST, + ChannelFunctionTypes.NATS_REPLY, + ChannelFunctionTypes.NATS_PUBLISH, + ChannelFunctionTypes.NATS_SUBSCRIBE, + ChannelFunctionTypes.NATS_JETSTREAM_PULL_SUBSCRIBE, + ChannelFunctionTypes.NATS_JETSTREAM_PUSH_SUBSCRIBE, + ChannelFunctionTypes.NATS_JETSTREAM_PUBLISH + ].includes(f) + ) + ) { + continue; + } const payload = payloads.operationModels[findOperationId(operation, channel)]; if (!payload) { throw new Error( - `Could not find payload for operation in channel typescript generator` + `Could not find payload for operation in channel typescript generator for NATS` ); } - const {messageModule, messageType} = getMessageTypeAndModule(payload); + const { messageModule, messageType } = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error( + `Could not find message type for channel typescript generator for NATS` + ); + } const updatedContext = { ...natsContext, messageType, @@ -197,9 +217,14 @@ async function handleReplyOperation( return renders; } - const {messageModule: replyMessageModule, messageType: replyMessageType} = + const { messageModule: replyMessageModule, messageType: replyMessageType } = getMessageTypeAndModule(replyMessageModel); + if (replyMessageType === undefined) { + throw new Error( + `Could not find reply message type for channel typescript generator for NATS` + ); + } if ( shouldRenderFunctionType( functionTypeMapping, @@ -251,8 +276,8 @@ async function handleNonReplyOperation( const renders: SingleFunctionRenderType[] = []; const action = operation.action(); const renderChecks = [ - {check: ChannelFunctionTypes.NATS_PUBLISH, render: renderCorePublish}, - {check: ChannelFunctionTypes.NATS_SUBSCRIBE, render: renderCoreSubscribe}, + { check: ChannelFunctionTypes.NATS_PUBLISH, render: renderCorePublish }, + { check: ChannelFunctionTypes.NATS_SUBSCRIBE, render: renderCoreSubscribe }, { check: ChannelFunctionTypes.NATS_JETSTREAM_PULL_SUBSCRIBE, render: renderJetstreamPullSubscribe @@ -267,7 +292,7 @@ async function handleNonReplyOperation( } ]; - for (const {check, render} of renderChecks) { + for (const { check, render } of renderChecks) { if ( shouldRenderFunctionType( functionTypeMapping, @@ -288,7 +313,7 @@ async function generateForChannels( natsContext: RenderRegularParameters ): Promise { const renders: SingleFunctionRenderType[] = []; - const {generator, payloads} = context; + const { generator, payloads } = context; const functionTypeMapping = getFunctionTypeMappingFromAsyncAPI(channel) ?? generator.functionTypeMapping[channel.id()]; @@ -298,8 +323,13 @@ async function generateForChannels( throw new Error(`Could not find payload for channel typescript generator`); } - const {messageModule, messageType} = getMessageTypeAndModule(payload); - const updatedContext = {...natsContext, messageType, messageModule}; + const { messageModule, messageType } = getMessageTypeAndModule(payload); + if (messageType === undefined) { + throw new Error( + `Could not find message type for channel typescript generator for NATS` + ); + } + const updatedContext = { ...natsContext, messageType, messageModule }; const renderChecks = [ { @@ -329,7 +359,7 @@ async function generateForChannels( } ]; - for (const {check, render, action} of renderChecks) { + for (const { check, render, action } of renderChecks) { if ( shouldRenderFunctionType( functionTypeMapping, diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index ffe524c1..ca3a9432 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -159,11 +159,11 @@ export interface RenderHttpParameters { requestTopic: string; requestMessageType?: string; servers?: string[]; - requestMessageModule?: string | undefined; + requestMessageModule: string | undefined; replyMessageType: string; replyMessageModule: string | undefined; channelParameters: ConstrainedObjectModel | undefined; - statusCodes?: {code: number, description:string}[] + statusCodes?: {code: number, description:string, messageModule?: string, messageType?: string}[] subName?: string; functionName?: string; method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; diff --git a/src/codegen/generators/typescript/channels/utils.ts b/src/codegen/generators/typescript/channels/utils.ts index c5f53bd6..c3da7126 100644 --- a/src/codegen/generators/typescript/channels/utils.ts +++ b/src/codegen/generators/typescript/channels/utils.ts @@ -9,7 +9,7 @@ export function addPayloadsToDependencies( currentGenerator: {outputPath: string}, dependencies: string[] ) { - models.forEach((payload) => { + models.filter((payload) => payload).forEach((payload) => { const payloadImportPath = path.relative( currentGenerator.outputPath, path.resolve(payloadGenerator.outputPath, payload.messageModel.modelName) @@ -29,7 +29,7 @@ export function addPayloadsToExports( models: ChannelPayload[], dependencies: string[] ) { - models.forEach((payload) => { + models.filter((payload) => payload).forEach((payload) => { if (payload.messageModel.model instanceof ConstrainedObjectModel) { dependencies.push(`export {${payload.messageModel.modelName}};`); } else { @@ -73,6 +73,9 @@ export function addParametersToExports( }); } export function getMessageTypeAndModule(payload: ChannelPayload) { + if (payload === undefined) { + return {messageType: undefined, messageModule: undefined}; + } let messageModule; if (!(payload.messageModel.model instanceof ConstrainedObjectModel)) { messageModule = `${payload.messageType}Module`; @@ -93,9 +96,9 @@ export function getValidationFunctions({ let validatorCreation = ''; let validationFunction = ''; if (includeValidation) { - validatorCreation = `const validator = ${messageModule ? messageModule : messageType}.createValidator();`; + validatorCreation = `const validator = ${messageModule ?? messageType}.createValidator();`; validationFunction = `if(!skipMessageValidation) { - const {valid, errors} = ${messageModule ? messageModule : messageType}.validate({data: receivedData, ajvValidatorFunction: validator}); + const {valid, errors} = ${messageModule ?? messageType}.validate({data: receivedData, ajvValidatorFunction: validator}); if(!valid) { ${onValidationFail} } diff --git a/src/codegen/generators/typescript/payloads.ts b/src/codegen/generators/typescript/payloads.ts index 99a54e45..1cfe2c66 100644 --- a/src/codegen/generators/typescript/payloads.ts +++ b/src/codegen/generators/typescript/payloads.ts @@ -209,6 +209,57 @@ function renderUnionUnmarshal( }`; } +/** + * Render status code based unmarshal function for union models + */ +function renderUnionUnmarshalByStatusCode( + model: ConstrainedUnionModel, + renderer: TypeScriptRenderer +) { + // Check if the union has status codes information + const hasStatusCodes = model.originalInput && model.originalInput['x-modelina-has-status-codes']; + + if (!hasStatusCodes) { + return ''; + } + + // Extract status codes from union members + const statusCodeChecks: string[] = []; + const unionMembers = model.union; + + for (const unionMember of unionMembers) { + if (unionMember instanceof ConstrainedReferenceModel && unionMember.ref instanceof ConstrainedObjectModel) { + const memberOriginalInput = unionMember.ref.originalInput; + if (memberOriginalInput && memberOriginalInput['x-modelina-status-codes']) { + const statusCode = memberOriginalInput['x-modelina-status-codes']; + + let codeValue: number; + if (typeof statusCode === 'object' && statusCode.code !== undefined) { + codeValue = statusCode.code; + } else if (typeof statusCode === 'number') { + codeValue = statusCode; + } else { + continue; // Skip invalid status codes + } + + statusCodeChecks.push(` if (statusCode === ${codeValue}) { + return ${unionMember.type}.unmarshal(json); + }`); + } + } + } + + if (statusCodeChecks.length === 0) { + return ''; + } + + return ` +export function unmarshalByStatusCode(json: any, statusCode: number): ${model.name} { +${statusCodeChecks.join('\n')} + throw new Error(\`No matching type found for status code: \${statusCode}\`); +}`; +} + /** * Safe stringify that removes x- properties and circular references by assuming true */ @@ -341,7 +392,8 @@ export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOp return validate; } ${renderUnionUnmarshal(model, renderer)} -${renderUnionMarshal(model)}`; +${renderUnionMarshal(model)} +${renderUnionUnmarshalByStatusCode(model, renderer)}`; } return content; } diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index bca87d4e..d7389491 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -1071,15 +1071,44 @@ await js.publish('user.signedup', dataToSend, options); exports[`channels typescript should work with request and reply AsyncAPI 1`] = ` "import * as TestPayloadModelModule from './../../../../TestPayloadModel'; -import { URLSearchParams } from 'url'; +import { URLSearchParams, URL } from 'url'; import * as NodeFetch from 'node-fetch'; export const Protocols = { http_client: { async getPingRequest(context: { server?: string; - payload: undefined; + path?: string; - accessToken?: string; + bearerToken?: string; + username?: string; + password?: string; + apiKey?: string; // API key value + apiKeyName?: string; // Name of the API key parameter + apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header) + // OAuth2 parameters + oauth2?: { + clientId: string; + clientSecret?: string; + accessToken?: string; + refreshToken?: string; + tokenUrl?: string; + authorizationUrl?: string; + redirectUri?: string; + scopes?: string[]; + flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter + // For password flow + username?: string; // Username for password flow + password?: string; // Password for password flow + onTokenRefresh?: (newTokens: { + accessToken: string; + refreshToken?: string; + expiresIn?: number; + }) => void; + // For Implicit flow + responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow + state?: string; // For security against CSRF + onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow + }; credentials?: RequestCredentials; //value for the credentials param we want to use on each request additionalHeaders?: Record; //header params we want to use on every request, makeRequestCallback?: ({ @@ -1107,36 +1136,307 @@ http_client: { }) }, path: '/ping', - server: localhost:3000, + server: 'localhost:3000', + apiKeyIn: 'header', + apiKeyName: 'X-API-Key', }, ...context, } + + // Validate parameters before proceeding with the request + // OAuth2 Implicit flow validation + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') { + if (!parsedContext.oauth2.authorizationUrl) { + return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Implicit flow requires clientId')); + } + if (!parsedContext.oauth2.redirectUri) { + return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri')); + } + if (!parsedContext.oauth2.onImplicitRedirect) { + return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler')); + } + } + + // OAuth2 Client Credentials flow validation + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') { + if (!parsedContext.oauth2.tokenUrl) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId')); + } + } + + // OAuth2 Password flow validation + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') { + if (!parsedContext.oauth2.tokenUrl) { + return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Password flow requires clientId')); + } + if (!parsedContext.oauth2.username) { + return Promise.reject(new Error('OAuth2 Password flow requires username')); + } + if (!parsedContext.oauth2.password) { + return Promise.reject(new Error('OAuth2 Password flow requires password')); + } + } + const headers = { - 'Content-Type': 'application/json', - ...parsedContext.additionalHeaders + 'Content-Type': 'application/json', + ...parsedContext.additionalHeaders }; - const url = \`\${parsedContext.server}\${parsedContext.path}\`; + let url = \`\${parsedContext.server}\${parsedContext.path}\`; let body: any; - if (parsedContext.payload) { - body = parsedContext.payload.marshal(); + + + // Handle different authentication methods + if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) { + // OAuth2 authentication with existing access token + headers["Authorization"] = \`Bearer \${parsedContext.oauth2.accessToken}\`; + } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) { + // Build the authorization URL for implicit flow + const authUrl = new URL(parsedContext.oauth2.authorizationUrl); + authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId); + authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!); + authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token'); + + if (parsedContext.oauth2.state) { + authUrl.searchParams.append('state', parsedContext.oauth2.state); + } + + if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) { + authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' ')); + } + + // Call the redirect handler + parsedContext.oauth2.onImplicitRedirect(authUrl.toString()); + // Since we've initiated a redirect flow, we can't continue with the request + // The application will need to handle the redirect and subsequent token extraction + return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated')); + } else if (parsedContext.bearerToken) { + // bearer authentication + headers["Authorization"] = \`Bearer \${parsedContext.bearerToken}\`; + } else if (parsedContext.username && parsedContext.password) { + // basic authentication + const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64'); + headers["Authorization"] = \`Basic \${credentials}\`; } - if (parsedContext.accessToken) { - // oauth required - headers["Authorization"] = parsedContext.accessToken; + // API Key Authentication + if (parsedContext.apiKey) { + if (parsedContext.apiKeyIn === 'header') { + // Add API key to headers + headers[parsedContext.apiKeyName] = parsedContext.apiKey; + } else if (parsedContext.apiKeyIn === 'query') { + // Add API key to query parameters + const separator = url.includes('?') ? '&' : '?'; + url = \`\${url}\${separator}\${parsedContext.apiKeyName}=\${encodeURIComponent(parsedContext.apiKey)}\`; + } } + // Make the API request const response = await parsedContext.makeRequestCallback({url, method: 'GET', headers, body }); - if (response.ok) { - const data = await response.json(); - return MessageTypeModule.MessageType.unmarshal(data); - } - return Promise.reject(new Error(response.statusText)); + + // Handle OAuth2 Client Credentials flow + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) { + try { + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: parsedContext.oauth2.clientId + }); + + if (parsedContext.oauth2.clientSecret) { + params.append('client_secret', parsedContext.oauth2.clientSecret); + } + + if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) { + params.append('scope', parsedContext.oauth2.scopes.join(' ')); + } + + // Some APIs use basic auth with client credentials instead of form params + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + // If both client ID and secret are provided, some servers prefer basic auth + if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) { + const credentials = Buffer.from( + \`\${parsedContext.oauth2.clientId}:\${parsedContext.oauth2.clientSecret}\` + ).toString('base64'); + authHeaders['Authorization'] = \`Basic \${credentials}\`; + // Remove client_id and client_secret from the request body when using basic auth + params.delete('client_id'); + params.delete('client_secret'); + } + + const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, { + method: 'POST', + headers: authHeaders, + body: params.toString() + }); + + if (tokenResponse.ok) { + const tokenData = await tokenResponse.json(); + const tokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Update headers with the new token + headers["Authorization"] = \`Bearer \${tokens.accessToken}\`; + + // Notify the client about the tokens + if (parsedContext.oauth2.onTokenRefresh) { + parsedContext.oauth2.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const retryResponse = await parsedContext.makeRequestCallback({ + url, + method: "GET", + headers, + body + }); + + const data = await retryResponse.json(); + return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); + } else { + return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`)); + } + } catch (error) { + console.error('Error in OAuth2 Client Credentials flow:', error); + return Promise.reject(error); + } + } + + // Handle OAuth2 password flow + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) { + try { + const params = new URLSearchParams({ + grant_type: 'password', + username: parsedContext.oauth2.username || '', + password: parsedContext.oauth2.password || '', + client_id: parsedContext.oauth2.clientId, + }); + + if (parsedContext.oauth2.clientSecret) { + params.append('client_secret', parsedContext.oauth2.clientSecret); + } + + if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) { + params.append('scope', parsedContext.oauth2.scopes.join(' ')); + } + + const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (tokenResponse.ok) { + const tokenData = await tokenResponse.json(); + const tokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Update headers with the new token + headers["Authorization"] = \`Bearer \${tokens.accessToken}\`; + + // Notify the client about the tokens + if (parsedContext.oauth2.onTokenRefresh) { + parsedContext.oauth2.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const retryResponse = await parsedContext.makeRequestCallback({ + url, + method: "GET", + headers, + body + }); + + const data = await retryResponse.json(); + return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); + + } else { + return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`)); + } + } catch (error) { + console.error('Error in OAuth2 password flow:', error); + return Promise.reject(error); + } + } + + // Handle token refresh for OAuth2 if we get a 401 + if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) { + try { + const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: parsedContext.oauth2.refreshToken, + client_id: parsedContext.oauth2.clientId, + ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {}) + }).toString() + }); + + if (refreshResponse.ok) { + const tokenData = await refreshResponse.json(); + const newTokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken, + expiresIn: tokenData.expires_in + }; + + // Update the access token for this request + headers["Authorization"] = \`Bearer \${newTokens.accessToken}\`; + + // Notify the client about the refreshed tokens + if (parsedContext.oauth2.onTokenRefresh) { + parsedContext.oauth2.onTokenRefresh(newTokens); + } + + // Retry the original request with the new token + const retryResponse = await parsedContext.makeRequestCallback({ + url, + method: "GET", + headers, + body + }); + + const data = await retryResponse.json(); + return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); + } else { + // Token refresh failed, return a standardized error message + return Promise.reject(new Error('Unauthorized')); + } + } catch (error) { + console.error('Error refreshing token:', error); + // For any error during refresh, return a standardized error message + return Promise.reject(new Error('Unauthorized')); + } + } + + const data = await response.json(); + return MessageTypeModule.unmarshalByStatusCode(data, response.status); } }};" `; diff --git a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap index ae4e2a37..72afd67d 100644 --- a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`payloads typescript should not render validation functions 1`] = ` -" -type UnionPayload = Record; +"import {SimpleObject} from './SimpleObject'; +type UnionPayload = SimpleObject | boolean | string; export { UnionPayload };" `; @@ -74,8 +74,44 @@ export { SimpleObject2 };" `; exports[`payloads typescript should work with basic AsyncAPI inputs 1`] = ` -" -type UnionPayload = Record; +"import {SimpleObject} from './SimpleObject'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormats from 'ajv-formats'; +type UnionPayload = SimpleObject | boolean | string; + +export const theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","oneOf":[{"type":"object","properties":{"type":{"const":"SimpleObject"},"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"SimpleObject"},{"type":"boolean","$id":"Boolean"},{"type":"string","$id":"String"}],"$id":"UnionPayload"}; +export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; +} +export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} +export function unmarshal(json: any): UnionPayload { + if(typeof json === 'object') { + if(json.type === 'SimpleObject') { + return SimpleObject.unmarshal(json); + } + } + return JSON.parse(json); +} +export function marshal(payload: UnionPayload) { + if(payload instanceof SimpleObject) { +return payload.marshal(); +} + + + return JSON.stringify(payload); +} + export { UnionPayload };" `; diff --git a/test/runtime/asyncapi-http.json b/test/runtime/asyncapi-http.json deleted file mode 100644 index a7bdecf7..00000000 --- a/test/runtime/asyncapi-http.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "asyncapi": "3.0.0", - "info": { - "title": "Runtime testing example", - "version": "1.0.0" - }, - "channels": { - "ping": { - "address": "/ping", - "messages": { - "ping": { - "$ref": "#/components/messages/ping" - }, - "pong": { - "$ref": "#/components/messages/pong" - } - } - } - }, - "operations": { - "pingPostRequest": { - "action": "send", - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/ping"} - ], - "bindings": { - "http": { - "method": "POST" - } - }, - "reply": { - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/pong"} - ] - } - }, - "pingGetRequest": { - "action": "send", - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ ], - "bindings": { - "http": { - "method": "GET" - } - }, - "reply": { - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/pong"} - ] - } - }, - }, - "components": { - "messages": { - "UserSignedUp": { - "payload": { - "$ref": "#/components/schemas/UserSignedUpPayload" - }, - "headers": { - "$ref": "#/components/schemas/UserSignedUpPayload" - } - }, - "ping": { - "payload": { - "type": "object", - "properties": { - "ping": { - "type": "string", - "description": "ping name" - } - } - } - }, - "pong": { - "payload": { - "type": "object", - "properties": { - "pong": { - "type": "string", - "description": "pong name" - } - } - }, - "bindings": { - "http": { - "statusCode": "200" - } - } - } - }, - "schemas": { - "UserSignedUpPayload": { - "type": "object", - "properties": { - "display_name": { - "type": "string", - "description": "Name of the user" - }, - "email": { - "type": "string", - "format": "email", - "description": "Email of the user" - } - } - } - } - } -} \ No newline at end of file diff --git a/test/runtime/asyncapi.json b/test/runtime/asyncapi.json deleted file mode 100644 index 82b77324..00000000 --- a/test/runtime/asyncapi.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "asyncapi": "3.0.0", - "info": { - "title": "Runtime testing example", - "version": "1.0.0" - }, - "channels": { - "userSignedup": { - "address": "user/signedup/{my_parameter}/{enum_parameter}", - "parameters": { - "my_parameter": { - "description": "parameter description" - }, - "enum_parameter": { - "description": "enum parameter", - "enum":["openapi", "asyncapi"] - } - }, - "messages": { - "UserSignedUp": { - "$ref": "#/components/messages/UserSignedUp" - } - } - }, - "noParameter": { - "address": "noparameters", - "messages": { - "UserSignedUp": { - "$ref": "#/components/messages/UserSignedUp" - } - } - }, - "ping": { - "address": "/ping", - "messages": { - "ping": { - "$ref": "#/components/messages/ping" - }, - "pong": { - "$ref": "#/components/messages/pong" - } - } - } - }, - "operations": { - "sendUserSignedup": { - "action": "send", - "channel": { - "$ref": "#/channels/userSignedup" - }, - "messages": [ - { - "$ref": "#/channels/userSignedup/messages/UserSignedUp" - } - ] - }, - "receiveUserSignedup": { - "action": "receive", - "channel": { - "$ref": "#/channels/userSignedup" - }, - "messages": [ - { - "$ref": "#/channels/userSignedup/messages/UserSignedUp" - } - ] - }, - "pingGetRequest": { - "action": "send", - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/ping"} - ], - "bindings": { - "http": { - "method": "POST" - } - }, - "reply": { - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/pong"} - ] - } - }, - "pongReply": { - "action": "receive", - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/pong"} - ], - "reply": { - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/ping"} - ] - } - } - }, - "components": { - "messages": { - "UserSignedUp": { - "payload": { - "$ref": "#/components/schemas/UserSignedUpPayload" - }, - "headers": { - "$ref": "#/components/schemas/UserSignedUpPayload" - } - }, - "ping": { - "payload": { - "type": "object", - "properties": { - "ping": { - "type": "string", - "description": "ping name" - } - } - } - }, - "pong": { - "payload": { - "type": "object", - "properties": { - "pong": { - "type": "string", - "description": "pong name" - } - } - }, - "bindings": { - "http": { - "statusCode": "200" - } - } - } - }, - "schemas": { - "UserSignedUpPayload": { - "type": "object", - "properties": { - "display_name": { - "type": "string", - "description": "Name of the user" - }, - "email": { - "type": "string", - "format": "email", - "description": "Email of the user" - } - } - } - } - } -} \ No newline at end of file diff --git a/test/runtime/typescript/codegen-request-reply.mjs b/test/runtime/typescript/codegen-request-reply.mjs index 35020e35..e99779f7 100644 --- a/test/runtime/typescript/codegen-request-reply.mjs +++ b/test/runtime/typescript/codegen-request-reply.mjs @@ -1,7 +1,7 @@ /** @type {import("../../../dist").TheCodegenConfiguration} **/ export default { inputType: 'asyncapi', - inputPath: '../asyncapi.json', + inputPath: '../asyncapi-request-reply.json', language: 'typescript', generators: [ { diff --git a/test/runtime/typescript/codegen.mjs b/test/runtime/typescript/codegen.mjs deleted file mode 100644 index 2814884a..00000000 --- a/test/runtime/typescript/codegen.mjs +++ /dev/null @@ -1,36 +0,0 @@ -/** @type {import("../../../").TheCodegenConfiguration} **/ -export default { - inputType: 'asyncapi', - inputPath: '../asyncapi.json', - language: 'typescript', - generators: [ - { - preset: 'payloads', - outputPath: './src/payloads', - serializationType: 'json', - }, - { - preset: 'parameters', - outputPath: './src/parameters', - }, - { - preset: 'headers', - outputPath: './src/headers', - }, - { - preset: 'types', - outputPath: './src', - }, - { - preset: 'channels', - outputPath: './src/channels', - eventSourceDependency: '@ai-zen/node-fetch-event-source', - protocols: ['nats', 'kafka', 'mqtt', 'event_source', 'amqp', 'http_client'] - }, - { - preset: 'client', - outputPath: './src/client', - protocols: ['nats'] - } - ] -}; diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json index 98ed74df..53342725 100644 --- a/test/runtime/typescript/package.json +++ b/test/runtime/typescript/package.json @@ -1,6 +1,6 @@ { "scripts": { - "test": "npm run test:kafka && npm run test:nats && npm run test:mqtt && npm run test:amqp && npm run test:eventsource", + "test": "npm run test:kafka && npm run test:nats && npm run test:mqtt && npm run test:amqp && npm run test:eventsource && npm run test:http", "test:kafka": "jest -- ./test/channels/kafka.spec.ts", "test:nats": "npm run test:nats:client && npm run test:nats:channels", "test:nats:channels": "jest -- ./test/channels/nats.spec.ts", @@ -8,9 +8,14 @@ "test:mqtt": "jest -- ./test/channels/mqtt.spec.ts", "test:amqp": "jest -- ./test/channels/amqp.spec.ts", "test:eventsource": "jest -- ./test/channels/eventsource.spec.ts", + "test:http": "jest -- ./test/channels/http_client/http_client.spec.ts ./test/channels/http_client/api_auth.spec.ts ./test/channels/http_client/oauth2_client_credentials.spec.ts ./test/channels/http_client/oauth2_implicit_flow.spec.ts ./test/channels/http_client/oauth2_password_flow.spec.ts ./test/channels/http_client/oauth2_refresh_token.spec.ts", + "test:http:basic": "jest -- ./test/channels/http_client/basic_http_methods.spec.ts", + "test:http:auth": "jest -- ./test/channels/http_client/api_auth.spec.ts", + "test:http:oauth2": "jest -- ./test/channels/http_client/oauth2_client_credentials.spec.ts ./test/channels/http_client/oauth2_implicit_flow.spec.ts ./test/channels/http_client/oauth2_password_flow.spec.ts ./test/channels/http_client/oauth2_refresh_token.spec.ts", "generate": "npm run generate:regular && npm run generate:request:reply", "generate:request:reply": "node ../../../bin/run.mjs generate ./codegen-request-reply.mjs", - "generate:regular": "node ../../../bin/run.mjs generate" + "generate:regular": "node ../../../bin/run.mjs generate ./codegen-regular.mjs", + "debug:generate": "node --inspect-brk ../../../bin/run.mjs generate" }, "dependencies": { "@ai-zen/node-fetch-event-source": "^2.1.4", @@ -24,12 +29,15 @@ "kafkajs": "^2.2.4", "mqtt": "^5.10.3", "nats": "^2.26.0", - "ts-jest": "^27.0.5" + "node-fetch": "^2.6.7", + "ts-jest": "^27.0.5", + "body-parser": "^1.20.2" }, "devDependencies": { "@types/node-fetch": "^2.6.12", "@types/amqplib": "^0.10.6", "@types/express": "^4.17.21", + "@types/body-parser": "^1.19.5", "jest-fetch-mock": "^3.0.3" } } diff --git a/test/runtime/typescript/test/channels/amqp.spec.ts b/test/runtime/typescript/test/channels/amqp.spec.ts deleted file mode 100644 index 462e954f..00000000 --- a/test/runtime/typescript/test/channels/amqp.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* eslint-disable no-console */ -import { Protocols } from '../../src/channels/index'; -const { amqp } = Protocols -const {publishToSendUserSignedupQueue, subscribeToReceiveUserSignedupQueue, publishToNoParameterQueue, subscribeToNoParameterQueue} = amqp; -import amqplib from 'amqplib'; -import { UserSignedUp } from '../../src/payloads/UserSignedUp'; -import { UserSignedupParameters } from '../../src/parameters/UserSignedupParameters'; - -describe('amqp', () => { - const testMessage = new UserSignedUp({displayName: 'test', email: 'test@test.dk'}); - const invalidMessage = new UserSignedUp({displayName: 'test', email: '123'}); - const testParameters = new UserSignedupParameters({myParameter: 'test', enumParameter: 'asyncapi'}); - let connection; - beforeAll(async () => { - connection = await amqplib.connect('amqp://0.0.0.0'); - connection.on('error', console.error); - }) - afterAll(async () => { - await connection.close(); - }) - describe('channels', () => { - describe('with parameters', () => { - it('should be able to publish to queue', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const channel = await subscribeToReceiveUserSignedupQueue({ - onDataCallback: (err, message, amqpMsg) => { - expect(message!.marshal()).toEqual(testMessage.marshal()); - channel.ack(amqpMsg!); - resolve(); - }, - parameters: testParameters, - amqp: connection, - options: { - noAck: true - } - }); - channel.on('error', (err) => { - reject(err); - }); - await channel.prefetch(1); - - await publishToSendUserSignedupQueue({message: testMessage, parameters: testParameters, amqp: connection}); - }); - }); - it('should be able to catch invalid message', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const channel = await subscribeToReceiveUserSignedupQueue({ - onDataCallback: (err) => { - expect(err).toBeDefined(); - expect(err?.message).toEqual('Invalid message payload received'); - expect(err?.cause).toBeDefined(); - resolve() - }, - parameters: testParameters, - amqp: connection, - options: { - noAck: true - } - }); - channel.on('error', (err) => { - reject(err); - }); - await channel.prefetch(1); - - await publishToSendUserSignedupQueue({message: invalidMessage, parameters: testParameters, amqp: connection}); - }); - }); - // TODO: cannot create exchange - // it('should be able to publish to exchange', () => { - // // eslint-disable-next-line no-async-promise-executor - // return new Promise(async (resolve, reject) => { - // const conn = await amqplib.connect('amqp://localhost'); - // const exchange = testParameters.getChannelWithParameters('user/signedup/{my_parameter}/{enum_parameter}'); - // const ch1 = await conn.createChannel(); - // await ch1.assertExchange(exchange, 'direct'); - - // ch1.consume(exchange, (msg) => { - // try{ - // if (msg !== null) { - // const message = UserSignedUp - // .unmarshal(msg.content.toString()) - // expect(message.marshal()).toEqual(testMessage.marshal()); - // resolve() - // } else { - // reject(); - // } - // } catch(e) { - // reject(e) - // } - // }); - - // await publishToSendUserSignedupExchange(testMessage, testParameters, conn, {exchange: exchange}); - // }); - // }); - }); - describe('without parameters', () => { - it('should be able to publish to queue', () => { - return new Promise(async (resolve, reject) => { - - const channel = await subscribeToNoParameterQueue({ - onDataCallback: (err, message, amqpMsg) => { - if (message) { - expect(message.marshal()).toEqual(testMessage.marshal()); - channel.ack(amqpMsg!); - resolve() - } else { - reject(); - } - }, - amqp: connection, - options: { - noAck: true - } - }); - channel.on('error', (err) => { - reject(err); - }); - await channel.prefetch(1); - await publishToNoParameterQueue({message: testMessage, amqp: connection}); - }); - }); - }); - }); -}); diff --git a/test/runtime/typescript/test/channels/eventsource.spec.ts b/test/runtime/typescript/test/channels/eventsource.spec.ts deleted file mode 100644 index 2f274d97..00000000 --- a/test/runtime/typescript/test/channels/eventsource.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* eslint-disable no-console */ -import { Protocols } from '../../src/channels/index'; -import { UserSignedupParameters } from '../../src/parameters/UserSignedupParameters'; -import { UserSignedUp } from '../../src/payloads/UserSignedUp'; -import express, { Router } from 'express' -require('jest-fetch-mock').dontMock() -const { event_source } = Protocols; -const { listenForNoParameter, registerNoParameter, registerSendUserSignedup, listenForReceiveUserSignedup } = event_source; - -describe('event source', () => { - const testPort = () => Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779; - const testMessage = new UserSignedUp({ displayName: 'test', email: 'test@test.dk' }); - const invalidMessage = new UserSignedUp({ displayName: 'test', email: '123' }); - const testParameters = new UserSignedupParameters({ myParameter: 'test', enumParameter: 'asyncapi' }); - describe('channels', () => { - describe('without parameters', () => { - let server; - afterEach(() => { - server?.close() - }) - it('should be able to send events and receive them', () => { - return new Promise(async (resolve, reject) => { - const router = Router() - const app = express() - app.use(express.json({ limit: '3000kb' })) - app.use(express.urlencoded({ extended: true })) - registerNoParameter({router, callback: (req, res, next, sendEvent) => { - sendEvent(testMessage); - res.end(); - }}) - app.use(router) - const portToUse = testPort() - server = app.listen(portToUse, async () => { - await listenForNoParameter({callback: (err, msg) => { - try { - expect(msg?.marshal()).toEqual(testMessage.marshal()); - resolve(); - } catch (e) { - reject(e); - } - }, options: { - baseUrl: 'http://localhost:' + portToUse, - }}) - }) - }); - }); - }); - describe('with parameters', () => { - let server; - afterEach(() => { - server?.close() - }) - it('should be able to send events and receive them', () => { - return new Promise(async (resolve, reject) => { - const router = Router() - const app = express() - app.use(express.json({ limit: '3000kb' })) - app.use(express.urlencoded({ extended: true })) - registerSendUserSignedup({router, callback: (req, res, next, parameters, sendEvent) => { - sendEvent(testMessage); - res.end(); - }}) - app.use(router) - const portToUse = testPort() - server = app.listen(portToUse, async () => { - await listenForReceiveUserSignedup({callback: (err, msg) => { - try { - expect(msg?.marshal()).toEqual(testMessage.marshal()); - resolve(); - } catch (e) { - reject(e); - } - }, - parameters: testParameters, - options: { - baseUrl: 'http://localhost:' + portToUse, - }}) - }) - }); - }); - it('should be able to catch validation errors', () => { - return new Promise(async (resolve, reject) => { - const router = Router() - const app = express() - app.use(express.json({ limit: '3000kb' })) - app.use(express.urlencoded({ extended: true })) - registerSendUserSignedup({router, callback: (req, res, next, parameters, sendEvent) => { - sendEvent(invalidMessage); - res.end(); - }}) - app.use(router) - const portToUse = testPort() - server = app.listen(portToUse, async () => { - await listenForReceiveUserSignedup({ - callback: (err) => { - try { - expect(err).toBeDefined(); - expect(err?.message).toEqual('Invalid message payload received'); - expect(err?.cause).toBeDefined(); - resolve(); - } catch (e) { - reject(e); - } - }, - parameters: testParameters, - options: { - baseUrl: 'http://localhost:' + portToUse, - }} - ) - }) - }); - }); - }); - }); -}); diff --git a/test/runtime/typescript/test/channels/http_client.spec.ts b/test/runtime/typescript/test/channels/http_client.spec.ts deleted file mode 100644 index 14a656b5..00000000 --- a/test/runtime/typescript/test/channels/http_client.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable no-console */ -import { Protocols } from '../../src/request-reply/channels/index'; -import { Ping } from "../../src/request-reply/payloads/Ping"; -import { Pong } from "../../src/request-reply/payloads/Pong"; -import express, { Router } from 'express'; -const {http_client } = Protocols; -const {postPingGetRequest } = http_client; - -jest.setTimeout(10000); -describe('http_fetch', () => { - const portToUse = Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779; - describe('channels', () => { - let server; - afterEach(() => { - server?.close(); - }); - it('should be able to make POST request', async () => { - return new Promise(async (resolve, reject) => { - const router = Router(); - const app = express(); - app.use(express.json({ limit: '3000kb' })); - app.use(express.urlencoded({ extended: true })); - app.use(router); - const requestMessage = new Ping({}); - const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); - - router.post('/ping', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.write(replyMessage.marshal()); - res.end(); - }); - server = app.listen(portToUse, async () => { - const receivedReplyMessage = await postPingGetRequest({payload: requestMessage, server: `http://localhost:${portToUse}`}); - expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); - resolve(); - }); - }); - }); - }); -}); diff --git a/test/runtime/typescript/test/channels/kafka.spec.ts b/test/runtime/typescript/test/channels/kafka.spec.ts deleted file mode 100644 index c375daf7..00000000 --- a/test/runtime/typescript/test/channels/kafka.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Protocols } from '../../src/channels'; -const { kafka } = Protocols; -const { - produceToNoParameter, consumeFromNoParameter, consumeFromReceiveUserSignedup, produceToSendUserSignedup } = kafka; -import { Kafka } from 'kafkajs'; -import { UserSignedupParameters } from '../../src/parameters/UserSignedupParameters'; -import { UserSignedUp } from '../../src/payloads/UserSignedUp'; -const kafkaClient = new Kafka({ - clientId: 'test', - brokers: ['localhost:9093'], -}); -jest.setTimeout(10000); -function createRandomString(length) { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} - -describe('kafka', () => { - const testMessage = new UserSignedUp({displayName: 'test', email: 'test@test.dk'}); - const invalidMessage = new UserSignedUp({displayName: 'test', email: '123'}); - const testParameters = new UserSignedupParameters({myParameter: 'test', enumParameter: 'asyncapi'}); - const admin = kafkaClient.admin({retry: {retries: 5, initialRetryTime: 1000}}); - beforeAll(async () => { - await admin.connect(); - await admin.createTopics({ - topics: [ - { topic: 'user.signedup.test.asyncapi', numPartitions: 1 }, - { topic: 'noparameters', numPartitions: 1 }, - ], - }); - }); - beforeEach(async() => { - try { - await admin.deleteTopicRecords({ - topic: 'user.signedup.test.asyncapi', - partitions: [{ partition: 0, offset: '-1' }], - }); - await admin.deleteTopicRecords({ - topic: 'noparameters', - partitions: [{ partition: 0, offset: '-1' }], - }); - } catch (e) {} - }); - afterAll(async () => { - await admin.disconnect(); - }); - describe('channels', () => { - describe('with parameters', () => { - it('should get error on incorrect message', () => { - // eslint-disable-next-line no-async-promise-executor - let consumer; - return new Promise(async (resolve, reject) => { - consumer = await consumeFromReceiveUserSignedup({onDataCallback: async (err, msg, parameters) => { - try { - expect(msg).toBeUndefined(); - expect(err).toBeDefined(); - expect(err?.message).toEqual('Invalid message payload received'); - expect(err?.cause).toBeDefined(); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - resolve(); - } catch (error) { - reject(error); - } - }, parameters: testParameters, kafka: kafkaClient, options: {fromBeginning: true, groupId: createRandomString(10)}}); - const producer = await produceToSendUserSignedup({message: invalidMessage, parameters: testParameters, kafka: kafkaClient}); - await producer.disconnect(); - }).finally(async () => { - if (consumer) { - await consumer.stop(); - await consumer.disconnect(); - } - }); - }); - it('should be able to publish and consume', () => { - // eslint-disable-next-line no-async-promise-executor - let consumer; - return new Promise(async (resolve, reject) => { - consumer = await consumeFromReceiveUserSignedup({onDataCallback: async (err, msg, parameters) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual(testMessage.marshal()); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - resolve(); - } catch (error) { - reject(error); - } - }, parameters: testParameters, kafka: kafkaClient, options: {fromBeginning: true, groupId: createRandomString(10)}}); - const producer = await produceToSendUserSignedup({message: testMessage, parameters: testParameters, kafka: kafkaClient}); - await producer.disconnect(); - }).finally(async () => { - if (consumer) { - await consumer.stop(); - await consumer.disconnect(); - } - }); - }) - }); - describe('without parameters', () => { - it('should be able to publish and consume', () => { - // eslint-disable-next-line no-async-promise-executor - let consumer; - return new Promise(async (resolve, reject) => { - consumer = await consumeFromNoParameter({ - onDataCallback: async (err, msg) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual(testMessage.marshal()); - resolve(); - } catch (error) { - reject(error); - } - }, - kafka: kafkaClient, - options: {fromBeginning: true, groupId: createRandomString(10)}} - ); - const producer = await produceToNoParameter({message: testMessage, kafka: kafkaClient}); - await producer.disconnect(); - }).finally(async () => { - if (consumer) { - await consumer.stop(); - await consumer.disconnect(); - } - }); - }); - }); - }); -}); diff --git a/test/runtime/typescript/test/channels/mqtt.spec.ts b/test/runtime/typescript/test/channels/mqtt.spec.ts deleted file mode 100644 index ba2b351d..00000000 --- a/test/runtime/typescript/test/channels/mqtt.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable no-console */ -import { Protocols } from '../../src/channels/index'; -import { UserSignedupParameters } from '../../src/parameters/UserSignedupParameters'; -import { UserSignedUp } from '../../src/payloads/UserSignedUp'; -const { mqtt } = Protocols; -const { publishToNoParameter, publishToSendUserSignedup } = mqtt; -import * as MqttClient from 'mqtt'; - -describe('mqtt', () => { - const testMessage = new UserSignedUp({displayName: 'test', email: 'test@test.dk'}); - const testParameters = new UserSignedupParameters({myParameter: 'test', enumParameter: 'asyncapi'}); - describe('channels', () => { - describe('with parameters', () => { - it('should be able to publish core', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const client = await MqttClient.connectAsync("mqtt://0.0.0.0:1883"); - await client.subscribeAsync("user/signedup/+/+"); - client.on("message", (topic, message) => { - const messageData = UserSignedUp.unmarshal(message.toString()) - expect(messageData.marshal()).toEqual(testMessage.marshal()) - expect(topic).toEqual(`user/signedup/${testParameters.myParameter}/${testParameters.enumParameter}`) - client.end(); - resolve(); - }); - await publishToSendUserSignedup({message: testMessage, parameters: testParameters, mqtt: client}); - }); - }); - }); - describe('without parameters', () => { - it('should be able to publish core', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const client = await MqttClient.connectAsync("mqtt://0.0.0.0:1883"); - await client.subscribeAsync("noparameters"); - client.on("message", (topic, message) => { - const messageData = UserSignedUp.unmarshal(message.toString()) - expect(messageData.marshal()).toEqual(testMessage.marshal()) - expect(topic).toEqual('noparameters') - client.end(); - resolve(); - }); - await publishToNoParameter({message: testMessage, mqtt: client}); - }); - }); - }); - }); -}); diff --git a/test/runtime/typescript/test/channels/nats.spec.ts b/test/runtime/typescript/test/channels/nats.spec.ts deleted file mode 100644 index 4b241191..00000000 --- a/test/runtime/typescript/test/channels/nats.spec.ts +++ /dev/null @@ -1,514 +0,0 @@ -/* eslint-disable no-console */ -import { AckPolicy, DeliverPolicy, JetStreamClient, JetStreamManager, NatsConnection, ReplayPolicy, ConsumerOpts, connect, JSONCodec } from "nats"; -import { UserSignedUp, UserSignedupParameters } from '../../src/client/NatsClient'; -import { Protocols } from '../../src/channels/index'; -import { Ping } from "../../src/payloads/Ping"; -import { Pong } from "../../src/payloads/Pong"; -const { nats } = Protocols; -const { - jetStreamPublishToSendUserSignedup, jetStreamPullSubscribeToReceiveUserSignedup, jetStreamPushSubscriptionFromReceiveUserSignedup, publishToSendUserSignedup, subscribeToReceiveUserSignedup, - jetStreamPublishToNoParameter, jetStreamPullSubscribeToNoParameter, jetStreamPushSubscriptionFromNoParameter, publishToNoParameter, subscribeToNoParameter, replyToPongReply, requestToPingRequest } = nats; - -describe('nats', () => { - const testMessage = new UserSignedUp({ displayName: 'test', email: 'test@test.dk' }); - const testParameters = new UserSignedupParameters({ myParameter: 'test', enumParameter: 'asyncapi' }); - - describe('channels', () => { - describe('with parameters', () => { - let nc: NatsConnection; - let js: JetStreamClient; - let jsm: JetStreamManager; - const test_stream = 'nats_channels_test'; - const test_subj = 'user.signedup.*.*'; - beforeAll(async () => { - nc = await connect({ servers: "nats://localhost:4443" }); - js = nc.jetstream(); - jsm = await nc.jetstreamManager(); - await jsm.streams.add({ name: test_stream, subjects: [test_subj] }); - }); - afterEach(async () => { - await jsm.streams.purge(test_stream); - }); - afterAll(async () => { - await jsm.streams.delete(test_stream); - // close the connection - const done = nc.closed(); - await nc.close(); - // check if the close was OK - const err = await done; - if (err) { - console.log(`error closing:`, err); - } - }); - - it('should be able publish over JetStream', async () => { - await jetStreamPublishToSendUserSignedup({ message: testMessage, parameters: testParameters, js }); - const msg = await jsm.streams.getMessage(test_stream, { last_by_subj: test_subj }); - expect(msg.json()).toEqual("{\"display_name\": \"test\",\"email\": \"test@test.dk\"}"); - }); - - describe('should be able to do pull subscribe', () => { - it('with correct payload', () => { - const config = { - stream: test_stream, - config: { - durable_name: 'jps_correct_payload', - ack_policy: AckPolicy.Explicit, - replay_policy: ReplayPolicy.Instant, - deliver_policy: DeliverPolicy.All, - }, - }; - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - js.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, testMessage.marshal()); - const subscriber = await jetStreamPullSubscribeToReceiveUserSignedup({ - onDataCallback: async (err, msg, parameters, jetstreamMsg) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual(testMessage.marshal()); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - jetstreamMsg?.ack(); - await subscriber.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - js, - options: config - }); - subscriber.pull({ batch: 1, expires: 10000 }); - }); - }); - - it('and catch incorrect payload', () => { - const config = { - stream: test_stream, - config: { - durable_name: 'jps_incorrect_payload', - ack_policy: AckPolicy.Explicit, - replay_policy: ReplayPolicy.Instant, - deliver_policy: DeliverPolicy.All, - }, - }; - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const incorrectPayload = JSON.stringify({ displayName: 'test', email: '123' }); - const subscriber = await jetStreamPullSubscribeToReceiveUserSignedup({ - onDataCallback: async (err, _, parameters, jetstreamMsg) => { - try { - expect(err).toBeDefined(); - expect(err?.message).toEqual('Invalid message payload received'); - expect(err?.cause).toBeDefined(); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - jetstreamMsg?.ack(); - await subscriber.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - js, - options: config - }); - js.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPayload); - subscriber.pull({ batch: 1, expires: 10000 }); - }); - }); - - it('and ignore incorrect payload', () => { - const config = { - stream: test_stream, - config: { - durable_name: 'jps_ignore_payload', - ack_policy: AckPolicy.Explicit, - replay_policy: ReplayPolicy.Instant, - deliver_policy: DeliverPolicy.All, - }, - }; - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const incorrectPayload = JSON.stringify({ email: '123', displayName: 'test' }); - js.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPayload); - const subscriber = await jetStreamPullSubscribeToReceiveUserSignedup({ - onDataCallback: async (err, msg, parameters, jetstreamMsg) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual("{\"email\": \"123\",\"displayName\": \"test\"}"); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - jetstreamMsg?.ack(); - await subscriber.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - js, - options: config, - skipMessageValidation: true - }); - subscriber.pull({ batch: 2, expires: 10000 }); - }); - }); - }); - - it('should be able to publish core', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - nc.subscribe(test_subj, { - callback: async (err, msg) => { - try { - expect(err).toBeNull(); - expect(msg.json()).toEqual(testMessage.marshal()); - resolve(); - } catch (error) { - reject(error); - } - } - }); - await publishToSendUserSignedup({ message: testMessage, parameters: testParameters, nc }); - }); - }); - - describe('should be able to do core subscribe', () => { - it('with correct payload', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const subscribtion = await subscribeToReceiveUserSignedup({ - onDataCallback: async (err, msg, parameters) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual(testMessage.marshal()); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - await subscribtion.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - nc - }); - nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, testMessage.marshal()); - }); - }); - it('and catch incorrect payload', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const subscribtion = await subscribeToReceiveUserSignedup({ - onDataCallback: async (err, _, parameters) => { - try { - expect(err).toBeDefined(); - expect(err?.message).toEqual('Invalid message payload received'); - expect(err?.cause).toBeDefined(); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - await subscribtion.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - nc - }); - const incorrectPaylod = JSON.stringify({ displayName: 'test', email: '123' }) - nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPaylod); - }); - }); - it('and ignore incorrect payload', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const subscribtion = await subscribeToReceiveUserSignedup({ - onDataCallback: async (err, msg, parameters) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual("{\"email\": \"123\",\"displayName\": \"test\"}"); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - await subscribtion.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - nc, - skipMessageValidation: true - }); - const incorrectPaylod = JSON.stringify({ displayName: 'test', email: '123' }) - nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPaylod); - }); - }); - }); - - describe('should be able to do jetstream push subscribe', () => { - it('with correct payload', () => { - const config: Partial = { - stream: test_stream, - config: { - durable_name: 'jpushs_correct_payload', - ack_policy: AckPolicy.Explicit, - replay_policy: ReplayPolicy.Instant, - deliver_policy: DeliverPolicy.All, - deliver_subject: `ack_jps_correct_payload` - }, - }; - return new Promise(async (resolve, reject) => { - const subscription = await jetStreamPushSubscriptionFromReceiveUserSignedup({ - onDataCallback: async (err, msg, parameters, jetstreamMsg) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual(testMessage.marshal()); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - jetstreamMsg?.ack(); - await subscription.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - js, - options: config - }); - js.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, testMessage.marshal()); - }); - }); - it('and catch incorrect payload', () => { - const config: Partial = { - stream: test_stream, - config: { - durable_name: 'jpushs_incorrect_payload', - ack_policy: AckPolicy.Explicit, - replay_policy: ReplayPolicy.Instant, - deliver_policy: DeliverPolicy.All, - deliver_subject: `ack_jps_incorrect_payload` - }, - }; - return new Promise(async (resolve, reject) => { - const subscription = await jetStreamPushSubscriptionFromReceiveUserSignedup({ - onDataCallback: async (err, _, parameters, jetstreamMsg) => { - try { - expect(err).toBeDefined(); - expect(err?.message).toEqual('Invalid message payload received'); - expect(err?.cause).toBeDefined(); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - await subscription.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - js, - options: config - }); - const incorrectPaylod = JSON.stringify({ displayName: 'test', email: '123' }) - nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPaylod); - }); - }); - it('and ignore incorrect payload', () => { - const config: Partial = { - stream: test_stream, - config: { - durable_name: 'jpushs_ignore_payload', - ack_policy: AckPolicy.Explicit, - replay_policy: ReplayPolicy.Instant, - deliver_policy: DeliverPolicy.All, - deliver_subject: `ack_jps_ignore_payload` - }, - }; - return new Promise(async (resolve, reject) => { - const subscription = await jetStreamPushSubscriptionFromReceiveUserSignedup({ - onDataCallback: async (err, msg, parameters) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual("{\"email\": \"123\",\"displayName\": \"test\"}"); - expect(parameters?.myParameter).toEqual(testParameters.myParameter); - await subscription.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), - js, - options: config, - skipMessageValidation: true - }); - const incorrectPaylod = JSON.stringify({ displayName: 'test', email: '123' }) - nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPaylod); - }); - }); - }); - }); - describe('without parameters', () => { - let nc: NatsConnection; - let js: JetStreamClient; - let jsm: JetStreamManager; - const test_stream = 'noparameters_stream'; - const test_subj = 'noparameters'; - beforeAll(async () => { - nc = await connect({ servers: "nats://localhost:4443" }); - js = nc.jetstream(); - jsm = await nc.jetstreamManager(); - await jsm.streams.add({ name: test_stream, subjects: [test_subj] }); - }); - afterEach(async () => { - await jsm.streams.purge(test_stream); - }); - afterAll(async () => { - await jsm.streams.delete(test_stream); - // close the connection - const done = nc.closed(); - await nc.close(); - // check if the close was OK - const err = await done; - if (err) { - console.log(`error closing:`, err); - } - }); - - it('should be able publish over JetStream', async () => { - await jetStreamPublishToNoParameter({ message: testMessage, js }); - const msg = await jsm.streams.getMessage(test_stream, { last_by_subj: test_subj }); - expect(msg.json()).toEqual("{\"display_name\": \"test\",\"email\": \"test@test.dk\"}"); - }); - - it('should be able to do pull subscribe over JetStream', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const config = { - stream: test_stream, - config: { - ack_policy: AckPolicy.Explicit, - replay_policy: ReplayPolicy.Instant, - deliver_policy: DeliverPolicy.All, - }, - }; - js.publish(`noparameters`, testMessage.marshal()) - const subscriber = await jetStreamPullSubscribeToNoParameter({ - onDataCallback: async (err, msg, jetstreamMsg) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual(testMessage.marshal()); - jetstreamMsg?.ack(); - await subscriber.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - js, - options: config - }); - subscriber.pull({ batch: 1, expires: 10000 }); - }); - }); - - it('should be able to publish core', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - nc.subscribe(test_subj, { - callback: async (err, msg) => { - try { - expect(err).toBeNull(); - expect(msg.json()).toEqual(testMessage.marshal()); - resolve(); - } catch (error) { - reject(error); - } - } - }); - await publishToNoParameter({ message: testMessage, nc }); - }); - }); - - it('should be able to do core subscribe', () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const subscribtion = await subscribeToNoParameter({ - onDataCallback: async (err, msg) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual(testMessage.marshal()); - await subscribtion.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - nc - }); - nc.publish(`noparameters`, testMessage.marshal()); - }); - }); - - it('should be able to do jetstream push subscribe', () => { - const config: Partial = { - stream: test_stream, - config: { - durable_name: 'jetstream_push_subscribe', - ack_policy: AckPolicy.Explicit, - replay_policy: ReplayPolicy.Instant, - deliver_policy: DeliverPolicy.All, - deliver_subject: `ack_jetstream_push_subscribe` - }, - }; - return new Promise(async (resolve, reject) => { - const subscription = await jetStreamPushSubscriptionFromNoParameter({ - onDataCallback: async (err, msg, jetstreamMsg) => { - try { - expect(err).toBeUndefined(); - expect(msg?.marshal()).toEqual(testMessage.marshal()); - jetstreamMsg?.ack(); - await subscription.drain(); - resolve(); - } catch (error) { - reject(error); - } - }, - js, - options: config - }); - js.publish(`noparameters`, testMessage.marshal()); - }); - }); - - it('should be able to setup reply', async () => { - const requestMessage = new Ping({}) - const replyMessage = new Pong({ additionalProperties: new Map([['test', true]]) }) - const replyCallback = jest.fn().mockReturnValue(replyMessage); - await replyToPongReply({ onDataCallback: replyCallback, nc }); - const reply = await nc.request('ping', requestMessage.marshal()); - const decodedMsg = JSONCodec().decode(reply.data); - const msg = Pong.unmarshal(decodedMsg as any); - const expectedJson = msg.marshal(); - const actualJson = replyMessage.marshal(); - expect(expectedJson).toEqual(actualJson); - }); - - it('should be able to make request', async () => { - return new Promise(async (resolve, reject) => { - const requestMessage = new Ping({}) - const replyMessage = new Pong({ additionalProperties: new Map([['test', true]]) }) - let subscription = nc.subscribe('ping'); - (async () => { - for await (const msg of subscription) { - if (msg.reply) { - msg.respond(JSONCodec().encode(replyMessage.marshal())); - } else { - reject('expected reply') - } - } - })(); - const receivedReplyMessage = await requestToPingRequest({ requestMessage: requestMessage, nc }) - expect(receivedReplyMessage.marshal()).toEqual(replyMessage.marshal()) - resolve(); - }); - }); - }); - }); -}); From d7443789baabb7e57b7380dbf7a6d4f23ec3241b Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Fri, 23 May 2025 21:15:04 +0200 Subject: [PATCH 13/19] fix client --- test/runtime/asyncapi-regular.json | 86 ++++ test/runtime/asyncapi-request-reply.json | 325 ++++++++++++ test/runtime/typescript/codegen-regular.mjs | 36 ++ .../test/channels/regular/amqp.spec.ts | 126 +++++ .../test/channels/regular/eventsource.spec.ts | 115 +++++ .../test/channels/regular/kafka.spec.ts | 131 +++++ .../test/channels/regular/mqtt.spec.ts | 48 ++ .../test/channels/regular/nats.spec.ts | 479 ++++++++++++++++++ .../http_client/api_auth.spec.ts | 181 +++++++ .../http_client/http_client.spec.ts | 212 ++++++++ .../oauth2_client_credentials.spec.ts | 233 +++++++++ .../http_client/oauth2_implicit_flow.spec.ts | 170 +++++++ .../http_client/oauth2_password_flow.spec.ts | 233 +++++++++ .../http_client/oauth2_refresh_token.spec.ts | 266 ++++++++++ .../request_reply/http_client/test-utils.ts | 103 ++++ .../test/channels/request_reply/nats.spec.ts | 72 +++ 16 files changed, 2816 insertions(+) create mode 100644 test/runtime/asyncapi-regular.json create mode 100644 test/runtime/asyncapi-request-reply.json create mode 100644 test/runtime/typescript/codegen-regular.mjs create mode 100644 test/runtime/typescript/test/channels/regular/amqp.spec.ts create mode 100644 test/runtime/typescript/test/channels/regular/eventsource.spec.ts create mode 100644 test/runtime/typescript/test/channels/regular/kafka.spec.ts create mode 100644 test/runtime/typescript/test/channels/regular/mqtt.spec.ts create mode 100644 test/runtime/typescript/test/channels/regular/nats.spec.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/http_client/api_auth.spec.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/http_client/http_client.spec.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/http_client/oauth2_password_flow.spec.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/http_client/test-utils.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/nats.spec.ts diff --git a/test/runtime/asyncapi-regular.json b/test/runtime/asyncapi-regular.json new file mode 100644 index 00000000..d482e0c7 --- /dev/null +++ b/test/runtime/asyncapi-regular.json @@ -0,0 +1,86 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "Runtime testing example", + "version": "1.0.0" + }, + "channels": { + "userSignedup": { + "address": "user/signedup/{my_parameter}/{enum_parameter}", + "parameters": { + "my_parameter": { + "description": "parameter description" + }, + "enum_parameter": { + "description": "enum parameter", + "enum":["openapi", "asyncapi"] + } + }, + "messages": { + "UserSignedUp": { + "$ref": "#/components/messages/UserSignedUp" + } + } + }, + "noParameter": { + "address": "noparameters", + "messages": { + "UserSignedUp": { + "$ref": "#/components/messages/UserSignedUp" + } + } + } + }, + "operations": { + "sendUserSignedup": { + "action": "send", + "channel": { + "$ref": "#/channels/userSignedup" + }, + "messages": [ + { + "$ref": "#/channels/userSignedup/messages/UserSignedUp" + } + ] + }, + "receiveUserSignedup": { + "action": "receive", + "channel": { + "$ref": "#/channels/userSignedup" + }, + "messages": [ + { + "$ref": "#/channels/userSignedup/messages/UserSignedUp" + } + ] + } + }, + "components": { + "messages": { + "UserSignedUp": { + "payload": { + "$ref": "#/components/schemas/UserSignedUpPayload" + }, + "headers": { + "$ref": "#/components/schemas/UserSignedUpPayload" + } + } + }, + "schemas": { + "UserSignedUpPayload": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "description": "Name of the user" + }, + "email": { + "type": "string", + "format": "email", + "description": "Email of the user" + } + } + } + } + } +} \ No newline at end of file diff --git a/test/runtime/asyncapi-request-reply.json b/test/runtime/asyncapi-request-reply.json new file mode 100644 index 00000000..fd1571d1 --- /dev/null +++ b/test/runtime/asyncapi-request-reply.json @@ -0,0 +1,325 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "Runtime testing example", + "version": "1.0.0" + }, + "channels": { + "ping": { + "address": "/ping", + "messages": { + "ping": { + "$ref": "#/components/messages/ping" + }, + "pong": { + "$ref": "#/components/messages/pong" + }, + "notFound": { + "$ref": "#/components/messages/notFound" + } + } + } + }, + "operations": { + "pingPostRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/ping"} + ], + "bindings": { + "http": { + "method": "POST" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "regularRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/ping"} + ], + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["nats_request"] + } + }, + "regularReply": { + "action": "receive", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/ping"} + ], + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["nats_reply"] + } + }, + "pingGetRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ ], + "bindings": { + "http": { + "method": "GET" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "pingPutRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/ping"} + ], + "bindings": { + "http": { + "method": "PUT" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "pingDeleteRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ ], + "bindings": { + "http": { + "method": "DELETE" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "pingPatchRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/ping"} + ], + "bindings": { + "http": { + "method": "PATCH" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "pingHeadRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ ], + "bindings": { + "http": { + "method": "HEAD" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "pingOptionsRequest": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ ], + "bindings": { + "http": { + "method": "OPTIONS" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "multiStatusResponse": { + "action": "send", + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/ping"} + ], + "bindings": { + "http": { + "method": "GET" + } + }, + "reply": { + "channel": { + "$ref": "#/channels/ping" + }, + "messages": [ + {"$ref": "#/channels/ping/messages/pong"}, + {"$ref": "#/channels/ping/messages/notFound"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + } + }, + "components": { + "messages": { + "ping": { + "payload": { + "type": "object", + "properties": { + "ping": { + "type": "string", + "description": "ping name" + } + } + } + }, + "pong": { + "payload": { + "type": "object", + "properties": { + "pong": { + "type": "string", + "description": "pong name" + } + } + }, + "bindings": { + "http": { + "statusCode": 200 + } + } + }, + "notFound": { + "payload": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message" + }, + "code": { + "type": "string", + "description": "Error code" + } + } + }, + "bindings": { + "http": { + "statusCode": 404 + } + } + } + }, + "schemas": { + "UserSignedUpPayload": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "description": "Name of the user" + }, + "email": { + "type": "string", + "format": "email", + "description": "Email of the user" + } + } + } + } + } +} \ No newline at end of file diff --git a/test/runtime/typescript/codegen-regular.mjs b/test/runtime/typescript/codegen-regular.mjs new file mode 100644 index 00000000..9310a494 --- /dev/null +++ b/test/runtime/typescript/codegen-regular.mjs @@ -0,0 +1,36 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'asyncapi', + inputPath: '../asyncapi-regular.json', + language: 'typescript', + generators: [ + { + preset: 'payloads', + outputPath: './src/payloads', + serializationType: 'json', + }, + { + preset: 'parameters', + outputPath: './src/parameters', + }, + { + preset: 'headers', + outputPath: './src/headers', + }, + { + preset: 'types', + outputPath: './src', + }, + { + preset: 'channels', + outputPath: './src/channels', + eventSourceDependency: '@ai-zen/node-fetch-event-source', + protocols: ['nats', 'kafka', 'mqtt', 'event_source', 'amqp'] + }, + { + preset: 'client', + outputPath: './src/client', + protocols: ['nats'] + } + ] +}; diff --git a/test/runtime/typescript/test/channels/regular/amqp.spec.ts b/test/runtime/typescript/test/channels/regular/amqp.spec.ts new file mode 100644 index 00000000..b99e924a --- /dev/null +++ b/test/runtime/typescript/test/channels/regular/amqp.spec.ts @@ -0,0 +1,126 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../src/channels/index'; +const { amqp } = Protocols +const {publishToSendUserSignedupQueue, subscribeToReceiveUserSignedupQueue, publishToNoParameterQueue, subscribeToNoParameterQueue} = amqp; +import amqplib from 'amqplib'; +import { UserSignedUp } from '../../../src/payloads/UserSignedUp'; +import { UserSignedupParameters } from '../../../src/parameters/UserSignedupParameters'; + +describe('amqp', () => { + const testMessage = new UserSignedUp({displayName: 'test', email: 'test@test.dk'}); + const invalidMessage = new UserSignedUp({displayName: 'test', email: '123'}); + const testParameters = new UserSignedupParameters({myParameter: 'test', enumParameter: 'asyncapi'}); + let connection; + beforeAll(async () => { + connection = await amqplib.connect('amqp://0.0.0.0'); + connection.on('error', console.error); + }) + afterAll(async () => { + await connection.close(); + }) + describe('channels', () => { + describe('with parameters', () => { + it('should be able to publish to queue', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const channel = await subscribeToReceiveUserSignedupQueue({ + onDataCallback: (err, message, amqpMsg) => { + expect(message!.marshal()).toEqual(testMessage.marshal()); + channel.ack(amqpMsg!); + resolve(); + }, + parameters: testParameters, + amqp: connection, + options: { + noAck: true + } + }); + channel.on('error', (err) => { + reject(err); + }); + await channel.prefetch(1); + + await publishToSendUserSignedupQueue({message: testMessage, parameters: testParameters, amqp: connection}); + }); + }); + it('should be able to catch invalid message', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const channel = await subscribeToReceiveUserSignedupQueue({ + onDataCallback: (err) => { + expect(err).toBeDefined(); + expect(err?.message).toEqual('Invalid message payload received'); + expect(err?.cause).toBeDefined(); + resolve() + }, + parameters: testParameters, + amqp: connection, + options: { + noAck: true + } + }); + channel.on('error', (err) => { + reject(err); + }); + await channel.prefetch(1); + + await publishToSendUserSignedupQueue({message: invalidMessage, parameters: testParameters, amqp: connection}); + }); + }); + // TODO: cannot create exchange + // it('should be able to publish to exchange', () => { + // // eslint-disable-next-line no-async-promise-executor + // return new Promise(async (resolve, reject) => { + // const conn = await amqplib.connect('amqp://localhost'); + // const exchange = testParameters.getChannelWithParameters('user/signedup/{my_parameter}/{enum_parameter}'); + // const ch1 = await conn.createChannel(); + // await ch1.assertExchange(exchange, 'direct'); + + // ch1.consume(exchange, (msg) => { + // try{ + // if (msg !== null) { + // const message = UserSignedUp + // .unmarshal(msg.content.toString()) + // expect(message.marshal()).toEqual(testMessage.marshal()); + // resolve() + // } else { + // reject(); + // } + // } catch(e) { + // reject(e) + // } + // }); + + // await publishToSendUserSignedupExchange(testMessage, testParameters, conn, {exchange: exchange}); + // }); + // }); + }); + describe('without parameters', () => { + it('should be able to publish to queue', () => { + return new Promise(async (resolve, reject) => { + + const channel = await subscribeToNoParameterQueue({ + onDataCallback: (err, message, amqpMsg) => { + if (message) { + expect(message.marshal()).toEqual(testMessage.marshal()); + channel.ack(amqpMsg!); + resolve() + } else { + reject(); + } + }, + amqp: connection, + options: { + noAck: true + } + }); + channel.on('error', (err) => { + reject(err); + }); + await channel.prefetch(1); + await publishToNoParameterQueue({message: testMessage, amqp: connection}); + }); + }); + }); + }); +}); diff --git a/test/runtime/typescript/test/channels/regular/eventsource.spec.ts b/test/runtime/typescript/test/channels/regular/eventsource.spec.ts new file mode 100644 index 00000000..423e399a --- /dev/null +++ b/test/runtime/typescript/test/channels/regular/eventsource.spec.ts @@ -0,0 +1,115 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../src/channels/index'; +import { UserSignedupParameters } from '../../../src/parameters/UserSignedupParameters'; +import { UserSignedUp } from '../../../src/payloads/UserSignedUp'; +import express, { Router } from 'express' +require('jest-fetch-mock').dontMock() +const { event_source } = Protocols; +const { listenForNoParameter, registerNoParameter, registerSendUserSignedup, listenForReceiveUserSignedup } = event_source; + +describe('event source', () => { + const testPort = () => Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779; + const testMessage = new UserSignedUp({ displayName: 'test', email: 'test@test.dk' }); + const invalidMessage = new UserSignedUp({ displayName: 'test', email: '123' }); + const testParameters = new UserSignedupParameters({ myParameter: 'test', enumParameter: 'asyncapi' }); + describe('channels', () => { + describe('without parameters', () => { + let server; + afterEach(() => { + server?.close() + }) + it('should be able to send events and receive them', () => { + return new Promise(async (resolve, reject) => { + const router = Router() + const app = express() + app.use(express.json({ limit: '3000kb' })) + app.use(express.urlencoded({ extended: true })) + registerNoParameter({router, callback: (req, res, next, sendEvent) => { + sendEvent(testMessage); + res.end(); + }}) + app.use(router) + const portToUse = testPort() + server = app.listen(portToUse, async () => { + await listenForNoParameter({callback: (err, msg) => { + try { + expect(msg?.marshal()).toEqual(testMessage.marshal()); + resolve(); + } catch (e) { + reject(e); + } + }, options: { + baseUrl: 'http://localhost:' + portToUse, + }}) + }) + }); + }); + }); + describe('with parameters', () => { + let server; + afterEach(() => { + server?.close() + }) + it('should be able to send events and receive them', () => { + return new Promise(async (resolve, reject) => { + const router = Router() + const app = express() + app.use(express.json({ limit: '3000kb' })) + app.use(express.urlencoded({ extended: true })) + registerSendUserSignedup({router, callback: (req, res, next, parameters, sendEvent) => { + sendEvent(testMessage); + res.end(); + }}) + app.use(router) + const portToUse = testPort() + server = app.listen(portToUse, async () => { + await listenForReceiveUserSignedup({callback: (err, msg) => { + try { + expect(msg?.marshal()).toEqual(testMessage.marshal()); + resolve(); + } catch (e) { + reject(e); + } + }, + parameters: testParameters, + options: { + baseUrl: 'http://localhost:' + portToUse, + }}) + }) + }); + }); + it('should be able to catch validation errors', () => { + return new Promise(async (resolve, reject) => { + const router = Router() + const app = express() + app.use(express.json({ limit: '3000kb' })) + app.use(express.urlencoded({ extended: true })) + registerSendUserSignedup({router, callback: (req, res, next, parameters, sendEvent) => { + sendEvent(invalidMessage); + res.end(); + }}) + app.use(router) + const portToUse = testPort() + server = app.listen(portToUse, async () => { + await listenForReceiveUserSignedup({ + callback: (err) => { + try { + expect(err).toBeDefined(); + expect(err?.message).toEqual('Invalid message payload received'); + expect(err?.cause).toBeDefined(); + resolve(); + } catch (e) { + reject(e); + } + }, + parameters: testParameters, + options: { + baseUrl: 'http://localhost:' + portToUse, + }} + ) + }) + }); + }); + }); + }); +}); diff --git a/test/runtime/typescript/test/channels/regular/kafka.spec.ts b/test/runtime/typescript/test/channels/regular/kafka.spec.ts new file mode 100644 index 00000000..b8b1c323 --- /dev/null +++ b/test/runtime/typescript/test/channels/regular/kafka.spec.ts @@ -0,0 +1,131 @@ +import { Protocols } from '../../../src/channels'; +const { kafka } = Protocols; +const { + produceToNoParameter, consumeFromNoParameter, consumeFromReceiveUserSignedup, produceToSendUserSignedup } = kafka; +import { Kafka } from 'kafkajs'; +import { UserSignedupParameters } from '../../../src/parameters/UserSignedupParameters'; +import { UserSignedUp } from '../../../src/payloads/UserSignedUp'; +const kafkaClient = new Kafka({ + clientId: 'test', + brokers: ['localhost:9093'], +}); +jest.setTimeout(10000); +function createRandomString(length) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +describe('kafka', () => { + const testMessage = new UserSignedUp({displayName: 'test', email: 'test@test.dk'}); + const invalidMessage = new UserSignedUp({displayName: 'test', email: '123'}); + const testParameters = new UserSignedupParameters({myParameter: 'test', enumParameter: 'asyncapi'}); + const admin = kafkaClient.admin({retry: {retries: 5, initialRetryTime: 1000}}); + beforeAll(async () => { + await admin.connect(); + await admin.createTopics({ + topics: [ + { topic: 'user.signedup.test.asyncapi', numPartitions: 1 }, + { topic: 'noparameters', numPartitions: 1 }, + ], + }); + }); + beforeEach(async() => { + try { + await admin.deleteTopicRecords({ + topic: 'user.signedup.test.asyncapi', + partitions: [{ partition: 0, offset: '-1' }], + }); + await admin.deleteTopicRecords({ + topic: 'noparameters', + partitions: [{ partition: 0, offset: '-1' }], + }); + } catch (e) {} + }); + afterAll(async () => { + await admin.disconnect(); + }); + describe('channels', () => { + describe('with parameters', () => { + it('should get error on incorrect message', () => { + // eslint-disable-next-line no-async-promise-executor + let consumer; + return new Promise(async (resolve, reject) => { + consumer = await consumeFromReceiveUserSignedup({onDataCallback: async (err, msg, parameters) => { + try { + expect(msg).toBeUndefined(); + expect(err).toBeDefined(); + expect(err?.message).toEqual('Invalid message payload received'); + expect(err?.cause).toBeDefined(); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + resolve(); + } catch (error) { + reject(error); + } + }, parameters: testParameters, kafka: kafkaClient, options: {fromBeginning: true, groupId: createRandomString(10)}}); + const producer = await produceToSendUserSignedup({message: invalidMessage, parameters: testParameters, kafka: kafkaClient}); + await producer.disconnect(); + }).finally(async () => { + if (consumer) { + await consumer.stop(); + await consumer.disconnect(); + } + }); + }); + it('should be able to publish and consume', () => { + // eslint-disable-next-line no-async-promise-executor + let consumer; + return new Promise(async (resolve, reject) => { + consumer = await consumeFromReceiveUserSignedup({onDataCallback: async (err, msg, parameters) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual(testMessage.marshal()); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + resolve(); + } catch (error) { + reject(error); + } + }, parameters: testParameters, kafka: kafkaClient, options: {fromBeginning: true, groupId: createRandomString(10)}}); + const producer = await produceToSendUserSignedup({message: testMessage, parameters: testParameters, kafka: kafkaClient}); + await producer.disconnect(); + }).finally(async () => { + if (consumer) { + await consumer.stop(); + await consumer.disconnect(); + } + }); + }) + }); + describe('without parameters', () => { + it('should be able to publish and consume', () => { + // eslint-disable-next-line no-async-promise-executor + let consumer; + return new Promise(async (resolve, reject) => { + consumer = await consumeFromNoParameter({ + onDataCallback: async (err, msg) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual(testMessage.marshal()); + resolve(); + } catch (error) { + reject(error); + } + }, + kafka: kafkaClient, + options: {fromBeginning: true, groupId: createRandomString(10)}} + ); + const producer = await produceToNoParameter({message: testMessage, kafka: kafkaClient}); + await producer.disconnect(); + }).finally(async () => { + if (consumer) { + await consumer.stop(); + await consumer.disconnect(); + } + }); + }); + }); + }); +}); diff --git a/test/runtime/typescript/test/channels/regular/mqtt.spec.ts b/test/runtime/typescript/test/channels/regular/mqtt.spec.ts new file mode 100644 index 00000000..6ef38127 --- /dev/null +++ b/test/runtime/typescript/test/channels/regular/mqtt.spec.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../src/channels/index'; +import { UserSignedupParameters } from '../../../src/parameters/UserSignedupParameters'; +import { UserSignedUp } from '../../../src/payloads/UserSignedUp'; +const { mqtt } = Protocols; +const { publishToNoParameter, publishToSendUserSignedup } = mqtt; +import * as MqttClient from 'mqtt'; + +describe('mqtt', () => { + const testMessage = new UserSignedUp({displayName: 'test', email: 'test@test.dk'}); + const testParameters = new UserSignedupParameters({myParameter: 'test', enumParameter: 'asyncapi'}); + describe('channels', () => { + describe('with parameters', () => { + it('should be able to publish core', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const client = await MqttClient.connectAsync("mqtt://0.0.0.0:1883"); + await client.subscribeAsync("user/signedup/+/+"); + client.on("message", (topic, message) => { + const messageData = UserSignedUp.unmarshal(message.toString()) + expect(messageData.marshal()).toEqual(testMessage.marshal()) + expect(topic).toEqual(`user/signedup/${testParameters.myParameter}/${testParameters.enumParameter}`) + client.end(); + resolve(); + }); + await publishToSendUserSignedup({message: testMessage, parameters: testParameters, mqtt: client}); + }); + }); + }); + describe('without parameters', () => { + it('should be able to publish core', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const client = await MqttClient.connectAsync("mqtt://0.0.0.0:1883"); + await client.subscribeAsync("noparameters"); + client.on("message", (topic, message) => { + const messageData = UserSignedUp.unmarshal(message.toString()) + expect(messageData.marshal()).toEqual(testMessage.marshal()) + expect(topic).toEqual('noparameters') + client.end(); + resolve(); + }); + await publishToNoParameter({message: testMessage, mqtt: client}); + }); + }); + }); + }); +}); diff --git a/test/runtime/typescript/test/channels/regular/nats.spec.ts b/test/runtime/typescript/test/channels/regular/nats.spec.ts new file mode 100644 index 00000000..88f2d205 --- /dev/null +++ b/test/runtime/typescript/test/channels/regular/nats.spec.ts @@ -0,0 +1,479 @@ +/* eslint-disable no-console */ +import { AckPolicy, DeliverPolicy, JetStreamClient, JetStreamManager, NatsConnection, ReplayPolicy, ConsumerOpts, connect, JSONCodec } from "nats"; +import { UserSignedUp, UserSignedupParameters } from '../../../src/client/NatsClient'; +import { Protocols } from '../../../src/channels/index'; +const { nats } = Protocols; +const { + jetStreamPublishToSendUserSignedup, jetStreamPullSubscribeToReceiveUserSignedup, jetStreamPushSubscriptionFromReceiveUserSignedup, publishToSendUserSignedup, subscribeToReceiveUserSignedup, + jetStreamPublishToNoParameter, jetStreamPullSubscribeToNoParameter, jetStreamPushSubscriptionFromNoParameter, publishToNoParameter, subscribeToNoParameter } = nats; + +describe('nats', () => { + const testMessage = new UserSignedUp({ displayName: 'test', email: 'test@test.dk' }); + const testParameters = new UserSignedupParameters({ myParameter: 'test', enumParameter: 'asyncapi' }); + + describe('channels', () => { + describe('with parameters', () => { + let nc: NatsConnection; + let js: JetStreamClient; + let jsm: JetStreamManager; + const test_stream = 'nats_channels_test'; + const test_subj = 'user.signedup.*.*'; + beforeAll(async () => { + nc = await connect({ servers: "nats://localhost:4443" }); + js = nc.jetstream(); + jsm = await nc.jetstreamManager(); + await jsm.streams.add({ name: test_stream, subjects: [test_subj] }); + }); + afterEach(async () => { + await jsm.streams.purge(test_stream); + }); + afterAll(async () => { + await jsm.streams.delete(test_stream); + // close the connection + const done = nc.closed(); + await nc.close(); + // check if the close was OK + const err = await done; + if (err) { + console.log(`error closing:`, err); + } + }); + + it('should be able publish over JetStream', async () => { + await jetStreamPublishToSendUserSignedup({ message: testMessage, parameters: testParameters, js }); + const msg = await jsm.streams.getMessage(test_stream, { last_by_subj: test_subj }); + expect(msg.json()).toEqual("{\"display_name\": \"test\",\"email\": \"test@test.dk\"}"); + }); + + describe('should be able to do pull subscribe', () => { + it('with correct payload', () => { + const config = { + stream: test_stream, + config: { + durable_name: 'jps_correct_payload', + ack_policy: AckPolicy.Explicit, + replay_policy: ReplayPolicy.Instant, + deliver_policy: DeliverPolicy.All, + }, + }; + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + js.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, testMessage.marshal()); + const subscriber = await jetStreamPullSubscribeToReceiveUserSignedup({ + onDataCallback: async (err, msg, parameters, jetstreamMsg) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual(testMessage.marshal()); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + jetstreamMsg?.ack(); + await subscriber.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + js, + options: config + }); + subscriber.pull({ batch: 1, expires: 10000 }); + }); + }); + + it('and catch incorrect payload', () => { + const config = { + stream: test_stream, + config: { + durable_name: 'jps_incorrect_payload', + ack_policy: AckPolicy.Explicit, + replay_policy: ReplayPolicy.Instant, + deliver_policy: DeliverPolicy.All, + }, + }; + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const incorrectPayload = JSON.stringify({ displayName: 'test', email: '123' }); + const subscriber = await jetStreamPullSubscribeToReceiveUserSignedup({ + onDataCallback: async (err, _, parameters, jetstreamMsg) => { + try { + expect(err).toBeDefined(); + expect(err?.message).toEqual('Invalid message payload received'); + expect(err?.cause).toBeDefined(); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + jetstreamMsg?.ack(); + await subscriber.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + js, + options: config + }); + js.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPayload); + subscriber.pull({ batch: 1, expires: 10000 }); + }); + }); + + it('and ignore incorrect payload', () => { + const config = { + stream: test_stream, + config: { + durable_name: 'jps_ignore_payload', + ack_policy: AckPolicy.Explicit, + replay_policy: ReplayPolicy.Instant, + deliver_policy: DeliverPolicy.All, + }, + }; + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const incorrectPayload = JSON.stringify({ email: '123', displayName: 'test' }); + js.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPayload); + const subscriber = await jetStreamPullSubscribeToReceiveUserSignedup({ + onDataCallback: async (err, msg, parameters, jetstreamMsg) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual("{\"email\": \"123\",\"displayName\": \"test\"}"); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + jetstreamMsg?.ack(); + await subscriber.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + js, + options: config, + skipMessageValidation: true + }); + subscriber.pull({ batch: 2, expires: 10000 }); + }); + }); + }); + + it('should be able to publish core', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + nc.subscribe(test_subj, { + callback: async (err, msg) => { + try { + expect(err).toBeNull(); + expect(msg.json()).toEqual(testMessage.marshal()); + resolve(); + } catch (error) { + reject(error); + } + } + }); + await publishToSendUserSignedup({ message: testMessage, parameters: testParameters, nc }); + }); + }); + + describe('should be able to do core subscribe', () => { + it('with correct payload', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const subscribtion = await subscribeToReceiveUserSignedup({ + onDataCallback: async (err, msg, parameters) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual(testMessage.marshal()); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + await subscribtion.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + nc + }); + nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, testMessage.marshal()); + }); + }); + it('and catch incorrect payload', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const subscribtion = await subscribeToReceiveUserSignedup({ + onDataCallback: async (err, _, parameters) => { + try { + expect(err).toBeDefined(); + expect(err?.message).toEqual('Invalid message payload received'); + expect(err?.cause).toBeDefined(); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + await subscribtion.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + nc + }); + const incorrectPaylod = JSON.stringify({ displayName: 'test', email: '123' }) + nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPaylod); + }); + }); + it('and ignore incorrect payload', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const subscribtion = await subscribeToReceiveUserSignedup({ + onDataCallback: async (err, msg, parameters) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual("{\"email\": \"123\",\"displayName\": \"test\"}"); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + await subscribtion.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + nc, + skipMessageValidation: true + }); + const incorrectPaylod = JSON.stringify({ displayName: 'test', email: '123' }) + nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPaylod); + }); + }); + }); + + describe('should be able to do jetstream push subscribe', () => { + it('with correct payload', () => { + const config: Partial = { + stream: test_stream, + config: { + durable_name: 'jpushs_correct_payload', + ack_policy: AckPolicy.Explicit, + replay_policy: ReplayPolicy.Instant, + deliver_policy: DeliverPolicy.All, + deliver_subject: `ack_jps_correct_payload` + }, + }; + return new Promise(async (resolve, reject) => { + const subscription = await jetStreamPushSubscriptionFromReceiveUserSignedup({ + onDataCallback: async (err, msg, parameters, jetstreamMsg) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual(testMessage.marshal()); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + jetstreamMsg?.ack(); + await subscription.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + js, + options: config + }); + js.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, testMessage.marshal()); + }); + }); + it('and catch incorrect payload', () => { + const config: Partial = { + stream: test_stream, + config: { + durable_name: 'jpushs_incorrect_payload', + ack_policy: AckPolicy.Explicit, + replay_policy: ReplayPolicy.Instant, + deliver_policy: DeliverPolicy.All, + deliver_subject: `ack_jps_incorrect_payload` + }, + }; + return new Promise(async (resolve, reject) => { + const subscription = await jetStreamPushSubscriptionFromReceiveUserSignedup({ + onDataCallback: async (err, _, parameters, jetstreamMsg) => { + try { + expect(err).toBeDefined(); + expect(err?.message).toEqual('Invalid message payload received'); + expect(err?.cause).toBeDefined(); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + await subscription.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + js, + options: config + }); + const incorrectPaylod = JSON.stringify({ displayName: 'test', email: '123' }) + nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPaylod); + }); + }); + it('and ignore incorrect payload', () => { + const config: Partial = { + stream: test_stream, + config: { + durable_name: 'jpushs_ignore_payload', + ack_policy: AckPolicy.Explicit, + replay_policy: ReplayPolicy.Instant, + deliver_policy: DeliverPolicy.All, + deliver_subject: `ack_jps_ignore_payload` + }, + }; + return new Promise(async (resolve, reject) => { + const subscription = await jetStreamPushSubscriptionFromReceiveUserSignedup({ + onDataCallback: async (err, msg, parameters) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual("{\"email\": \"123\",\"displayName\": \"test\"}"); + expect(parameters?.myParameter).toEqual(testParameters.myParameter); + await subscription.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + parameters: new UserSignedupParameters({ myParameter: '*', enumParameter: 'asyncapi' }), + js, + options: config, + skipMessageValidation: true + }); + const incorrectPaylod = JSON.stringify({ displayName: 'test', email: '123' }) + nc.publish(`user.signedup.${testParameters.myParameter}.${testParameters.enumParameter}`, incorrectPaylod); + }); + }); + }); + }); + describe('without parameters', () => { + let nc: NatsConnection; + let js: JetStreamClient; + let jsm: JetStreamManager; + const test_stream = 'noparameters_stream'; + const test_subj = 'noparameters'; + beforeAll(async () => { + nc = await connect({ servers: "nats://localhost:4443" }); + js = nc.jetstream(); + jsm = await nc.jetstreamManager(); + await jsm.streams.add({ name: test_stream, subjects: [test_subj] }); + }); + afterEach(async () => { + await jsm.streams.purge(test_stream); + }); + afterAll(async () => { + await jsm.streams.delete(test_stream); + // close the connection + const done = nc.closed(); + await nc.close(); + // check if the close was OK + const err = await done; + if (err) { + console.log(`error closing:`, err); + } + }); + + it('should be able publish over JetStream', async () => { + await jetStreamPublishToNoParameter({ message: testMessage, js }); + const msg = await jsm.streams.getMessage(test_stream, { last_by_subj: test_subj }); + expect(msg.json()).toEqual("{\"display_name\": \"test\",\"email\": \"test@test.dk\"}"); + }); + + it('should be able to do pull subscribe over JetStream', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const config = { + stream: test_stream, + config: { + ack_policy: AckPolicy.Explicit, + replay_policy: ReplayPolicy.Instant, + deliver_policy: DeliverPolicy.All, + }, + }; + js.publish(`noparameters`, testMessage.marshal()) + const subscriber = await jetStreamPullSubscribeToNoParameter({ + onDataCallback: async (err, msg, jetstreamMsg) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual(testMessage.marshal()); + jetstreamMsg?.ack(); + await subscriber.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + js, + options: config + }); + subscriber.pull({ batch: 1, expires: 10000 }); + }); + }); + + it('should be able to publish core', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + nc.subscribe(test_subj, { + callback: async (err, msg) => { + try { + expect(err).toBeNull(); + expect(msg.json()).toEqual(testMessage.marshal()); + resolve(); + } catch (error) { + reject(error); + } + } + }); + await publishToNoParameter({ message: testMessage, nc }); + }); + }); + + it('should be able to do core subscribe', () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const subscribtion = await subscribeToNoParameter({ + onDataCallback: async (err, msg) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual(testMessage.marshal()); + await subscribtion.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + nc + }); + nc.publish(`noparameters`, testMessage.marshal()); + }); + }); + + it('should be able to do jetstream push subscribe', () => { + const config: Partial = { + stream: test_stream, + config: { + durable_name: 'jetstream_push_subscribe', + ack_policy: AckPolicy.Explicit, + replay_policy: ReplayPolicy.Instant, + deliver_policy: DeliverPolicy.All, + deliver_subject: `ack_jetstream_push_subscribe` + }, + }; + return new Promise(async (resolve, reject) => { + const subscription = await jetStreamPushSubscriptionFromNoParameter({ + onDataCallback: async (err, msg, jetstreamMsg) => { + try { + expect(err).toBeUndefined(); + expect(msg?.marshal()).toEqual(testMessage.marshal()); + jetstreamMsg?.ack(); + await subscription.drain(); + resolve(); + } catch (error) { + reject(error); + } + }, + js, + options: config + }); + js.publish(`noparameters`, testMessage.marshal()); + }); + }); + }); + }); +}); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/api_auth.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/api_auth.spec.ts new file mode 100644 index 00000000..c74c8c3b --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/http_client/api_auth.spec.ts @@ -0,0 +1,181 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../../src/request-reply/channels/index'; +import { Ping } from "../../../../src/request-reply/payloads/Ping"; +import { Pong } from "../../../../src/request-reply/payloads/Pong"; +import { createTestServer, runWithServer } from './test-utils'; +const { http_client } = Protocols; +const { postPingPostRequest } = http_client; + +jest.setTimeout(10000); +describe('HTTP Client - API Key and Basic Authentication', () => { + describe('Authentication Methods', () => { + it('should authenticate with API Key in header', async () => { + const { app, router, port } = createTestServer(); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const API_KEY = 'test-api-key-12345'; + const API_KEY_NAME = 'X-API-Key'; + + router.post('/ping', (req, res) => { + // Check if API key is present in the header + if (req.headers[API_KEY_NAME.toLowerCase()] !== API_KEY) { + return res.status(401).json({ error: 'Unauthorized - Invalid API Key' }); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + apiKey: API_KEY, + apiKeyName: API_KEY_NAME, + apiKeyIn: 'header' + }); + + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + + it('should authenticate with API Key in query parameter', async () => { + const { app, router, port } = createTestServer(); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const API_KEY = 'test-api-key-12345'; + const API_KEY_NAME = 'api_key'; + + router.post('/ping', (req, res) => { + // Check if API key is present in query params + if (req.query[API_KEY_NAME] !== API_KEY) { + return res.status(401).json({ error: 'Unauthorized - Invalid API Key' }); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + apiKey: API_KEY, + apiKeyName: API_KEY_NAME, + apiKeyIn: 'query' + }); + + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + + it('should authenticate with Basic Authentication', async () => { + const { app, router, port } = createTestServer(); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const USERNAME = 'testuser'; + const PASSWORD = 'testpassword'; + + router.post('/ping', (req, res) => { + // Check if Authorization header is present + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Basic ')) { + return res.status(401).json({ error: 'Unauthorized - Basic Authentication Required' }); + } + + // Decode and validate the Basic auth credentials + const base64Credentials = authHeader.split(' ')[1]; + const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); + const [username, password] = credentials.split(':'); + + if (username !== USERNAME || password !== PASSWORD) { + return res.status(401).json({ error: 'Unauthorized - Invalid Credentials' }); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + username: USERNAME, + password: PASSWORD + }); + + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + + it('should authenticate with Bearer Token', async () => { + const { app, router, port } = createTestServer(); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const BEARER_TOKEN = 'jwt-token-12345'; + + router.post('/ping', (req, res) => { + // Check if Authorization header is present + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized - Bearer Authentication Required' }); + } + + // Validate the Bearer token + const token = authHeader.split(' ')[1]; + if (token !== BEARER_TOKEN) { + return res.status(401).json({ error: 'Unauthorized - Invalid Token' }); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + bearerToken: BEARER_TOKEN + }); + + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + + it('should handle unauthorized errors with API Key', async () => { + const { app, router, port } = createTestServer(); + + const requestMessage = new Ping({}); + const API_KEY_NAME = 'X-API-Key'; + + router.post('/ping', (req, res) => { + // Always return unauthorized + res.status(401); + res.end(); + }); + + return runWithServer(app, port, async () => { + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + apiKey: 'wrong-api-key', + apiKeyName: API_KEY_NAME, + apiKeyIn: 'header' + }); + throw new Error('Expected request to fail with 401 status'); + } catch (error) { + expect(error.message).toBe('Unauthorized'); + } + }); + }); + }); +}); \ No newline at end of file diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/http_client.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/http_client.spec.ts new file mode 100644 index 00000000..12864c1e --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/http_client/http_client.spec.ts @@ -0,0 +1,212 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../../src/request-reply/channels/index'; +import { Ping } from "../../../../src/request-reply/payloads/Ping"; +import { Pong } from "../../../../src/request-reply/payloads/Pong"; +import { createTestServer, runWithServer } from './test-utils'; +import { NotFound } from '../../../../src/request-reply/payloads/NotFound'; +const {http_client } = Protocols; +const {postPingPostRequest, getPingGetRequest, putPingPutRequest, + patchPingPatchRequest, deletePingDeleteRequest, headPingHeadRequest, + optionsPingOptionsRequest, getMultiStatusResponse } = http_client; + +jest.setTimeout(10000); +describe('http_fetch', () => { + describe('channels', () => { + it('should be able to make POST request', async () => { + const { app, router, port } = createTestServer(); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + let requestMethod: string; + + router.post('/ping', (req, res) => { + requestMethod = req.method; + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}` + }); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + expect(requestMethod).toEqual('POST'); + }); + }); + + it('should be able to make GET request', async () => { + const { app, router, port } = createTestServer(); + + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + let requestMethod: string; + + router.get('/ping', (req, res) => { + requestMethod = req.method; + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await getPingGetRequest({ + server: `http://localhost:${port}` + }); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + expect(requestMethod).toEqual('GET'); + }); + }); + + it('should be able to make PUT request', async () => { + const { app, router, port } = createTestServer(); + + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + let requestMethod: string; + + router.put('/ping', (req, res) => { + requestMethod = req.method; + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await putPingPutRequest({ + server: `http://localhost:${port}` + }); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + expect(requestMethod).toEqual('PUT'); + }); + }); + + it('should be able to make PATCH request', async () => { + const { app, router, port } = createTestServer(); + + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + let requestMethod: string; + + router.patch('/ping', (req, res) => { + requestMethod = req.method; + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await patchPingPatchRequest({ + server: `http://localhost:${port}` + }); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + expect(requestMethod).toEqual('PATCH'); + }); + }); + + it('should be able to make DELETE request', async () => { + const { app, router, port } = createTestServer(); + + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + let requestMethod: string; + + router.delete('/ping', (req, res) => { + requestMethod = req.method; + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await deletePingDeleteRequest({ + server: `http://localhost:${port}` + }); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + expect(requestMethod).toEqual('DELETE'); + }); + }); + + it('should be able to make HEAD request', async () => { + const { app, router, port } = createTestServer(); + + let requestMethod: string; + + router.head('/ping', (req, res) => { + requestMethod = req.method; + res.setHeader('Content-Type', 'application/json'); + // HEAD responses typically don't have a body + res.end(); + }); + + return runWithServer(app, port, async () => { + try { + await headPingHeadRequest({ + server: `http://localhost:${port}` + }); + // If we reach here, the request didn't throw (which is what we expect for HEAD) + expect(requestMethod).toEqual('HEAD'); + } catch (error) { + // HEAD will likely fail because it's trying to parse JSON from an empty response + // This is actually expected behavior for this test framework + expect(requestMethod).toEqual('HEAD'); + expect(error.message).toContain('Unexpected end of JSON input'); + } + }); + }); + + it('should be able to make OPTIONS request', async () => { + const { app, router, port } = createTestServer(); + + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + let requestMethod: string; + + router.options('/ping', (req, res) => { + requestMethod = req.method; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await optionsPingOptionsRequest({ + server: `http://localhost:${port}` + }); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + expect(requestMethod).toEqual('OPTIONS'); + }); + }); + + it('should handle multi-status 200 response', async () => { + const { app, router, port } = createTestServer(); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + + router.get('/ping', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.status(200).send(replyMessage.marshal()); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await getMultiStatusResponse({ + server: `http://localhost:${port}` + }); + expect(receivedReplyMessage.marshal()).toEqual(replyMessage.marshal()); + }); + }); + it('should handle multi-status 404 response', async () => { + const { app, router, port } = createTestServer(); + const replyMessage = new NotFound({additionalProperties: new Map([['test', true]])}); + + router.get('/ping', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.status(404).send(replyMessage.marshal()); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await getMultiStatusResponse({ + server: `http://localhost:${port}` + }); + expect(receivedReplyMessage.marshal()).toEqual(replyMessage.marshal()); + }); + }); + + }); +}); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts new file mode 100644 index 00000000..298ce1da --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts @@ -0,0 +1,233 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../../src/request-reply/channels/index'; +import { Ping } from "../../../../src/request-reply/payloads/Ping"; +import { Pong } from "../../../../src/request-reply/payloads/Pong"; +import bodyParser from 'body-parser'; +import { createTestServer, runWithServer, createTokenResponse, TestResponses } from './test-utils'; +const { http_client } = Protocols; +const { postPingPostRequest } = http_client; + +jest.setTimeout(10000); +describe('HTTP Client - OAuth2 Client Credentials Flow', () => { + describe('OAuth2 Client Credentials', () => { + it('should authenticate with OAuth2 Client Credentials flow', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const CLIENT_ID = 'test-client-id'; + const CLIENT_SECRET = 'test-client-secret'; + const ACCESS_TOKEN = 'test-access-token-12345'; + + // Mock token endpoint + router.post('/oauth/token', (req, res) => { + // Validate grant type + if (req.body.grant_type !== 'client_credentials') { + return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid grant type' }); + } + + // Validate client credentials from body or Basic auth header + const authHeader = req.headers.authorization; + let clientId, clientSecret; + + if (authHeader && authHeader.startsWith('Basic ')) { + // Extract credentials from Basic auth header + const base64Credentials = authHeader.split(' ')[1]; + const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); + [clientId, clientSecret] = credentials.split(':'); + } else { + // Extract credentials from request body + clientId = req.body.client_id; + clientSecret = req.body.client_secret; + } + + if (clientId !== CLIENT_ID || (CLIENT_SECRET && clientSecret !== CLIENT_SECRET)) { + return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); + } + + // Return a successful token response + res.json(createTokenResponse({ + accessToken: ACCESS_TOKEN, + expiresIn: 3600 + })); + }); + + // Protected API endpoint that requires Bearer token + router.post('/ping', (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body); + } + + // Validate the Bearer token + const token = authHeader.split(' ')[1]; + if (token !== ACCESS_TOKEN) { + return res.status(401).json(TestResponses.unauthorized('Invalid Token').body); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + // Mock onTokenRefresh callback + const onTokenRefresh = jest.fn(); + + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'client_credentials', + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + tokenUrl: `http://localhost:${port}/oauth/token`, + onTokenRefresh + } + }); + + // Verify that the token refresh callback was called with the expected tokens + expect(onTokenRefresh).toHaveBeenCalledWith({ + accessToken: ACCESS_TOKEN, + refreshToken: undefined, + expiresIn: 3600 + }); + + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + + it('should handle OAuth2 client credentials errors', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const CLIENT_ID = 'test-client-id'; + + // Mock token endpoint that always fails + router.post('/oauth/token', (req, res) => { + res.status(401).json({ + error: 'invalid_client', + error_description: 'Invalid client credentials' + }); + }); + + return runWithServer(app, port, async () => { + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'client_credentials', + clientId: CLIENT_ID, + tokenUrl: `http://localhost:${port}/oauth/token` + } + }); + throw new Error('Expected request to fail with 401 status'); + } catch (error) { + expect(error.message).toContain('OAuth2 token request failed'); + } + }); + }); + + it('should handle missing clientId in client credentials flow', async () => { + const port = Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779; + const requestMessage = new Ping({}); + + try { + // Using as any to bypass TypeScript's type checking for this test + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'client_credentials', + tokenUrl: `http://localhost:${port}/oauth/token` + } as any + }); + throw new Error('Expected request to fail due to missing clientId'); + } catch (error) { + expect(error.message).toBe('OAuth2 Client Credentials flow requires clientId'); + } + }); + + it('should handle client credentials with Basic authentication', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const CLIENT_ID = 'test-client-id'; + const CLIENT_SECRET = 'test-client-secret'; + const ACCESS_TOKEN = 'test-access-token-12345'; + let usedBasicAuth = false; + + // Mock token endpoint + router.post('/oauth/token', (req, res) => { + // Validate grant type + if (req.body.grant_type !== 'client_credentials') { + return res.status(400).json(TestResponses.badRequest('Invalid grant type').body); + } + + // Check if Basic auth is used + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Basic ')) { + usedBasicAuth = true; + + // Extract and validate credentials + const base64Credentials = authHeader.split(' ')[1]; + const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); + const [clientId, clientSecret] = credentials.split(':'); + + if (clientId !== CLIENT_ID || clientSecret !== CLIENT_SECRET) { + return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body); + } + } else { + // If not using Basic auth, client credentials should be in the request body + return res.status(401).json(TestResponses.unauthorized('Basic authentication expected').body); + } + + // Return a successful token response + res.json(createTokenResponse({ + accessToken: ACCESS_TOKEN, + expiresIn: 3600 + })); + }); + + // Protected API endpoint + router.post('/ping', (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body); + } + + // Validate the Bearer token + const token = authHeader.split(' ')[1]; + if (token !== ACCESS_TOKEN) { + return res.status(401).json(TestResponses.unauthorized('Invalid Token').body); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'client_credentials', + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + tokenUrl: `http://localhost:${port}/oauth/token` + } + }); + + // Verify that Basic authentication was used + expect(usedBasicAuth).toBe(true); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts new file mode 100644 index 00000000..dbe33317 --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts @@ -0,0 +1,170 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../../src/request-reply/channels/index'; +import { Ping } from "../../../../src/request-reply/payloads/Ping"; +import { createTestServer, runWithServer } from './test-utils'; +const { http_client } = Protocols; +const { postPingPostRequest } = http_client; + +jest.setTimeout(10000); +describe('HTTP Client - OAuth2 Implicit Flow', () => { + describe('OAuth2 Implicit Flow', () => { + it('should build proper authorization URL for implicit flow', async () => { + const { app, port } = createTestServer(); + + const requestMessage = new Ping({}); + const CLIENT_ID = 'test-client-id'; + const REDIRECT_URI = 'http://localhost:3000/callback'; + const AUTH_URL = 'http://localhost:3000/oauth/authorize'; + const SCOPES = ['read', 'write']; + const STATE = 'random-state-value'; + let capturedAuthUrl = ''; + + // Mock the implicit redirect handler + const onImplicitRedirect = jest.fn((authUrl) => { + capturedAuthUrl = authUrl; + // In a real app, this would redirect the user to the authorization URL + }); + + return runWithServer(app, port, async () => { + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'implicit', + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + authorizationUrl: AUTH_URL, + scopes: SCOPES, + state: STATE, + responseType: 'token', + onImplicitRedirect + } + }); + throw new Error('Expected request to throw since implicit flow requires redirect'); + } catch (error) { + // Verify that the redirect handler was called + expect(onImplicitRedirect).toHaveBeenCalled(); + + // Verify that the authorization URL was constructed correctly + const url = new URL(capturedAuthUrl); + expect(url.origin + url.pathname).toBe(AUTH_URL); + expect(url.searchParams.get('client_id')).toBe(CLIENT_ID); + expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI); + expect(url.searchParams.get('response_type')).toBe('token'); + expect(url.searchParams.get('state')).toBe(STATE); + expect(url.searchParams.get('scope')).toBe(SCOPES.join(' ')); + + // Verify the correct error message + expect(error.message).toBe('OAuth2 Implicit flow redirect initiated'); + } + }); + }); + + it('should require clientId for implicit flow', async () => { + const requestMessage = new Ping({}); + const AUTH_URL = 'http://localhost:3000/oauth/authorize'; + + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:1234`, + oauth2: { + flow: 'implicit', + authorizationUrl: AUTH_URL, + // Missing clientId + onImplicitRedirect: jest.fn() + } as any + }); + throw new Error('Expected request to fail due to missing clientId'); + } catch (error) { + expect(error.message).toBe('OAuth2 Implicit flow requires clientId'); + } + }); + + it('should require redirectUri for implicit flow', async () => { + const requestMessage = new Ping({}); + const CLIENT_ID = 'test-client-id'; + const AUTH_URL = 'http://localhost:3000/oauth/authorize'; + + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:1234`, + oauth2: { + flow: 'implicit', + clientId: CLIENT_ID, + authorizationUrl: AUTH_URL, + // Missing redirectUri + onImplicitRedirect: jest.fn() + } + }); + throw new Error('Expected request to fail due to missing redirectUri'); + } catch (error) { + expect(error.message).toBe('OAuth2 Implicit flow requires redirectUri'); + } + }); + + it('should require onImplicitRedirect handler for implicit flow', async () => { + const requestMessage = new Ping({}); + const CLIENT_ID = 'test-client-id'; + const REDIRECT_URI = 'http://localhost:3000/callback'; + const AUTH_URL = 'http://localhost:3000/oauth/authorize'; + + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:12345`, + oauth2: { + flow: 'implicit', + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + authorizationUrl: AUTH_URL + // Missing onImplicitRedirect handler + } + }); + throw new Error('Expected request to fail due to missing redirect handler'); + } catch (error) { + expect(error.message).toBe('OAuth2 Implicit flow requires onImplicitRedirect handler'); + } + }); + + it('should support different response types for implicit flow', async () => { + const { app, port } = createTestServer(); + + const requestMessage = new Ping({}); + const CLIENT_ID = 'test-client-id'; + const REDIRECT_URI = 'http://localhost:3000/callback'; + const AUTH_URL = 'http://localhost:3000/oauth/authorize'; + const RESPONSE_TYPE = 'id_token token'; + let capturedAuthUrl = ''; + + // Mock the implicit redirect handler + const onImplicitRedirect = jest.fn((authUrl) => { + capturedAuthUrl = authUrl; + }); + + return runWithServer(app, port, async () => { + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'implicit', + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + authorizationUrl: AUTH_URL, + responseType: RESPONSE_TYPE, + onImplicitRedirect + } + }); + throw new Error('Expected request to throw since implicit flow requires redirect'); + } catch (error) { + // Verify that the response_type was included correctly + const url = new URL(capturedAuthUrl); + expect(url.searchParams.get('response_type')).toBe(RESPONSE_TYPE); + } + }); + }); + }); +}); \ No newline at end of file diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_password_flow.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_password_flow.spec.ts new file mode 100644 index 00000000..78a09b30 --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_password_flow.spec.ts @@ -0,0 +1,233 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../../src/request-reply/channels/index'; +import { Ping } from "../../../../src/request-reply/payloads/Ping"; +import { Pong } from "../../../../src/request-reply/payloads/Pong"; +import bodyParser from 'body-parser'; +import { createTestServer, runWithServer, createTokenResponse, TestResponses } from './test-utils'; +const { http_client } = Protocols; +const { postPingPostRequest } = http_client; + +jest.setTimeout(10000); +describe('HTTP Client - OAuth2 Password Flow', () => { + describe('OAuth2 Password Flow', () => { + it('should authenticate with OAuth2 Password flow', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const CLIENT_ID = 'test-client-id'; + const CLIENT_SECRET = 'test-client-secret'; + const ACCESS_TOKEN = 'test-access-token-12345'; + const REFRESH_TOKEN = 'test-refresh-token-12345'; + const USERNAME = 'testuser'; + const PASSWORD = 'testpassword'; + + // Mock token endpoint + router.post('/oauth/token', (req, res) => { + // Validate grant type + if (req.body.grant_type !== 'password') { + return res.status(400).json(TestResponses.badRequest('Invalid grant type').body); + } + + // Validate client ID + if (req.body.client_id !== CLIENT_ID) { + return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body); + } + + // Validate client secret if provided + if (CLIENT_SECRET && req.body.client_secret !== CLIENT_SECRET) { + return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body); + } + + // Validate username and password + if (req.body.username !== USERNAME || req.body.password !== PASSWORD) { + return res.status(401).json(TestResponses.unauthorized('Invalid username or password').body); + } + + // Return a successful token response + res.json(createTokenResponse({ + accessToken: ACCESS_TOKEN, + refreshToken: REFRESH_TOKEN, + expiresIn: 3600 + })); + }); + + // Protected API endpoint that requires Bearer token + router.post('/ping', (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body); + } + + // Validate the Bearer token + const token = authHeader.split(' ')[1]; + if (token !== ACCESS_TOKEN) { + return res.status(401).json(TestResponses.unauthorized('Invalid Token').body); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + // Mock onTokenRefresh callback + const onTokenRefresh = jest.fn(); + + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'password', + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + username: USERNAME, + password: PASSWORD, + tokenUrl: `http://localhost:${port}/oauth/token`, + onTokenRefresh + } + }); + + // Verify that the token refresh callback was called with the expected tokens + expect(onTokenRefresh).toHaveBeenCalledWith({ + accessToken: ACCESS_TOKEN, + refreshToken: REFRESH_TOKEN, + expiresIn: 3600 + }); + + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + + it('should handle invalid username/password', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const CLIENT_ID = 'test-client-id'; + const INVALID_USERNAME = 'wronguser'; + const INVALID_PASSWORD = 'wrongpassword'; + + // Mock token endpoint + router.post('/oauth/token', (req, res) => { + // Always return invalid grant for this test + res.status(401).json({ + error: 'invalid_grant', + error_description: 'Invalid username or password' + }); + }); + + return runWithServer(app, port, async () => { + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'password', + clientId: CLIENT_ID, + username: INVALID_USERNAME, + password: INVALID_PASSWORD, + tokenUrl: `http://localhost:${port}/oauth/token` + } + }); + throw new Error('Expected request to fail with 401 status'); + } catch (error) { + expect(error.message).toContain('OAuth2 token request failed'); + } + }); + }); + + it('should handle missing clientId in password flow', async () => { + const port = Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779; + const requestMessage = new Ping({}); + const USERNAME = 'testuser'; + const PASSWORD = 'testpassword'; + + try { + // Using as any to bypass TypeScript's type checking for this test + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'password', + username: USERNAME, + password: PASSWORD, + tokenUrl: `http://localhost:${port}/oauth/token` + } as any + }); + throw new Error('Expected request to fail due to missing clientId'); + } catch (error) { + expect(error.message).toBe('OAuth2 Password flow requires clientId'); + } + }); + + it('should handle password flow with scopes', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const CLIENT_ID = 'test-client-id'; + const ACCESS_TOKEN = 'test-access-token-12345'; + const USERNAME = 'testuser'; + const PASSWORD = 'testpassword'; + const SCOPES = ['read', 'write']; + let receivedScopes = ''; + + // Mock token endpoint + router.post('/oauth/token', (req, res) => { + // Validate grant type + if (req.body.grant_type !== 'password') { + return res.status(400).json(TestResponses.badRequest('Invalid grant type').body); + } + + // Store the received scopes for later verification + receivedScopes = req.body.scope; + + // Return a successful token response + res.json(createTokenResponse({ + accessToken: ACCESS_TOKEN, + expiresIn: 3600 + })); + }); + + // Protected API endpoint + router.post('/ping', (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body); + } + + // Validate the Bearer token + const token = authHeader.split(' ')[1]; + if (token !== ACCESS_TOKEN) { + return res.status(401).json(TestResponses.unauthorized('Invalid Token').body); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + flow: 'password', + clientId: CLIENT_ID, + username: USERNAME, + password: PASSWORD, + scopes: SCOPES, + tokenUrl: `http://localhost:${port}/oauth/token` + } + }); + + // Verify that the scopes were sent correctly + expect(receivedScopes).toBe(SCOPES.join(' ')); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts new file mode 100644 index 00000000..ff3b0b0b --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts @@ -0,0 +1,266 @@ +/* eslint-disable no-console */ +import { Protocols } from '../../../../src/request-reply/channels/index'; +import { Ping } from "../../../../src/request-reply/payloads/Ping"; +import { Pong } from "../../../../src/request-reply/payloads/Pong"; +import bodyParser from 'body-parser'; +import { createTestServer, runWithServer, createTokenResponse, TestResponses } from './test-utils'; +const { http_client } = Protocols; +const { postPingPostRequest } = http_client; + +jest.setTimeout(10000); +describe('HTTP Client - OAuth2 Refresh Token Flow', () => { + describe('OAuth2 Refresh Token', () => { + it('should refresh token when receiving 401 with an expired token', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const CLIENT_ID = 'test-client-id'; + const CLIENT_SECRET = 'test-client-secret'; + const EXPIRED_ACCESS_TOKEN = 'expired-token-12345'; + const REFRESH_TOKEN = 'refresh-token-12345'; + const NEW_ACCESS_TOKEN = 'new-access-token-12345'; + const NEW_REFRESH_TOKEN = 'new-refresh-token-12345'; + let tokenRefreshCalled = false; + + // Mock token endpoint for refresh + router.post('/oauth/token', (req, res) => { + // Check if this is a refresh token request + if (req.body.grant_type !== 'refresh_token') { + return res.status(400).json(TestResponses.badRequest('Invalid grant type').body); + } + + // Validate refresh token + if (req.body.refresh_token !== REFRESH_TOKEN) { + return res.status(401).json(TestResponses.unauthorized('Invalid refresh token').body); + } + + // Validate client credentials + if (req.body.client_id !== CLIENT_ID) { + return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body); + } + + if (CLIENT_SECRET && req.body.client_secret !== CLIENT_SECRET) { + return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body); + } + + tokenRefreshCalled = true; + + // Return a successful token response with a new access token and refresh token + res.json(createTokenResponse({ + accessToken: NEW_ACCESS_TOKEN, + refreshToken: NEW_REFRESH_TOKEN, + expiresIn: 3600 + })); + }); + + // Protected API endpoint that requires Bearer token + // First request will return 401, second request with refreshed token will succeed + let requestCount = 0; + router.post('/ping', (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body); + } + + // Get the token + const token = authHeader.split(' ')[1]; + + // First request with expired token returns 401 + if (requestCount === 0 && token === EXPIRED_ACCESS_TOKEN) { + requestCount++; + return res.status(401).json(TestResponses.unauthorized('Token Expired').body); + } + + // Second request with new token should succeed + if (requestCount === 1 && token === NEW_ACCESS_TOKEN) { + requestCount++; + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + return; + } + + // Unexpected token + res.status(401).json(TestResponses.unauthorized('Invalid Token').body); + }); + + return runWithServer(app, port, async () => { + // Mock onTokenRefresh callback + const onTokenRefresh = jest.fn(); + + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + accessToken: EXPIRED_ACCESS_TOKEN, + refreshToken: REFRESH_TOKEN, + tokenUrl: `http://localhost:${port}/oauth/token`, + onTokenRefresh + } + }); + + // Verify that token refresh was called + expect(tokenRefreshCalled).toBe(true); + + // Verify that the token refresh callback was called with the expected tokens + expect(onTokenRefresh).toHaveBeenCalledWith({ + accessToken: NEW_ACCESS_TOKEN, + refreshToken: NEW_REFRESH_TOKEN, + expiresIn: 3600 + }); + + // Verify that the request succeeded with the refreshed token + expect(requestCount).toBe(2); + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + + it('should handle refresh token errors', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const CLIENT_ID = 'test-client-id'; + const EXPIRED_ACCESS_TOKEN = 'expired-token-12345'; + const INVALID_REFRESH_TOKEN = 'invalid-refresh-token'; + + // Mock token endpoint that always fails refresh + router.post('/oauth/token', (req, res) => { + res.status(401).json(TestResponses.unauthorized('Invalid refresh token').body); + }); + + // Protected API endpoint that requires Bearer token + router.post('/ping', (req, res) => { + // Always return 401 for this test + res.status(401).json(TestResponses.unauthorized('Token Expired').body); + }); + + return runWithServer(app, port, async () => { + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + clientId: CLIENT_ID, + accessToken: EXPIRED_ACCESS_TOKEN, + refreshToken: INVALID_REFRESH_TOKEN, + tokenUrl: `http://localhost:${port}/oauth/token` + } + }); + throw new Error('Expected request to fail with 401 status'); + } catch (error) { + // Request should fail with the original 401 error since refresh failed + expect(error.message).toBe('Unauthorized'); + } + }); + }); + + it('should handle refresh token with missing required parameters', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const EXPIRED_ACCESS_TOKEN = 'expired-token-12345'; + // Missing clientId which is required for refresh + + // API endpoint that returns 401 + router.post('/ping', (req, res) => { + res.status(401).json(TestResponses.unauthorized('Token Expired').body); + }); + + return runWithServer(app, port, async () => { + try { + await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + accessToken: EXPIRED_ACCESS_TOKEN, + refreshToken: 'refresh-token', + tokenUrl: `http://localhost:${port}/oauth/token` + } as any // Using any to bypass type checking + }); + throw new Error('Expected request to fail'); + } catch (error) { + // The request should fail with the original 401 error + // since refresh can't be performed without clientId + expect(error.message).toBe('Unauthorized'); + } + }); + }); + + it('should preserve original refresh token if new one not returned', async () => { + const { app, router, port } = createTestServer(); + app.use(bodyParser.urlencoded({ extended: true })); + + const requestMessage = new Ping({}); + const replyMessage = new Pong({additionalProperties: new Map([['test', true]])}); + const CLIENT_ID = 'test-client-id'; + const EXPIRED_ACCESS_TOKEN = 'expired-token-12345'; + const REFRESH_TOKEN = 'refresh-token-12345'; + const NEW_ACCESS_TOKEN = 'new-access-token-12345'; + // No new refresh token in the response + + // Mock token endpoint for refresh + router.post('/oauth/token', (req, res) => { + // Return a successful token response with only a new access token + res.json(createTokenResponse({ + accessToken: NEW_ACCESS_TOKEN, + expiresIn: 3600 + // No refresh token + })); + }); + + // Protected API endpoint that requires Bearer token + let requestCount = 0; + router.post('/ping', (req, res) => { + const authHeader = req.headers.authorization; + const token = authHeader?.split(' ')[1]; + + // First request with expired token returns 401 + if (requestCount === 0 && token === EXPIRED_ACCESS_TOKEN) { + requestCount++; + return res.status(401).json(TestResponses.unauthorized('Token Expired').body); + } + + // Second request with new token should succeed + if (requestCount === 1 && token === NEW_ACCESS_TOKEN) { + requestCount++; + res.setHeader('Content-Type', 'application/json'); + res.write(replyMessage.marshal()); + res.end(); + } + }); + + return runWithServer(app, port, async () => { + // Mock onTokenRefresh callback + const onTokenRefresh = jest.fn(); + + const receivedReplyMessage = await postPingPostRequest({ + payload: requestMessage, + server: `http://localhost:${port}`, + oauth2: { + clientId: CLIENT_ID, + accessToken: EXPIRED_ACCESS_TOKEN, + refreshToken: REFRESH_TOKEN, + tokenUrl: `http://localhost:${port}/oauth/token`, + onTokenRefresh + } + }); + + // Verify that the token refresh callback was called with the new access token + // and the original refresh token preserved + expect(onTokenRefresh).toHaveBeenCalledWith({ + accessToken: NEW_ACCESS_TOKEN, + refreshToken: REFRESH_TOKEN, // Original refresh token preserved + expiresIn: 3600 + }); + + expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal()); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/test-utils.ts b/test/runtime/typescript/test/channels/request_reply/http_client/test-utils.ts new file mode 100644 index 00000000..0064a3cf --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/http_client/test-utils.ts @@ -0,0 +1,103 @@ +import express, { Router, Express } from 'express'; +import bodyParser from 'body-parser'; +import { Server } from 'http'; + +/** + * Helper function to create an Express server for HTTP client tests + */ +export function createTestServer(): { + app: Express; + router: Router; + port: number; +} { + const router = Router(); + const app = express(); + app.use(express.json({ limit: '3000kb' })); + app.use(express.urlencoded({ extended: true })); + app.use(bodyParser.urlencoded({ extended: true })); + app.use(router); + + // Generate a random port between 5779 and 9875 + const port = Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779; + + return { app, router, port }; +} + +/** + * Start an Express server and run the test function + * This handles proper server cleanup after the test + */ +export function runWithServer( + server: Express, + port: number, + testFn: (server: Server) => Promise +): Promise { + return new Promise((resolve, reject) => { + const httpServer = server.listen(port, async () => { + try { + await testFn(httpServer); + resolve(); + } catch (error) { + reject(error); + } finally { + httpServer.close(); + } + }); + }); +} + +/** + * Helper for creating standard OAuth2 token endpoint responses + */ +export function createTokenResponse({ + accessToken, + refreshToken, + expiresIn = 3600 +}: { + accessToken: string; + refreshToken?: string; + expiresIn?: number; +}) { + const response: { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + } = { + access_token: accessToken, + token_type: 'bearer', + expires_in: expiresIn + }; + + if (refreshToken) { + response.refresh_token = refreshToken; + } + + return response; +} + +/** + * Mock response data for tests + */ +export class TestResponses { + static unauthorized(message = 'Unauthorized') { + return { + status: 401, + body: { error: message } + }; + } + + static badRequest(message = 'Bad Request') { + return { + status: 400, + body: { error: message } + }; + } + + static ok(body: any) { + return { + status: 200, + body + }; + } +} \ No newline at end of file diff --git a/test/runtime/typescript/test/channels/request_reply/nats.spec.ts b/test/runtime/typescript/test/channels/request_reply/nats.spec.ts new file mode 100644 index 00000000..e21d0c51 --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/nats.spec.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-console */ +import { JetStreamClient, JetStreamManager, NatsConnection, connect, JSONCodec } from "nats"; +import { Protocols } from '../../../src/request-reply/channels/index'; +import { Ping } from '../../../src/request-reply/payloads/Ping'; +import { Pong } from '../../../src/request-reply/payloads/Pong'; +const { nats } = Protocols; +const { requestToRegularRequest, replyToRegularReply } = nats; + +describe('nats', () => { + describe('channels', () => { + describe('without parameters', () => { + let nc: NatsConnection; + let js: JetStreamClient; + let jsm: JetStreamManager; + const test_stream = 'noparameters_stream'; + const test_subj = 'noparameters'; + beforeAll(async () => { + nc = await connect({ servers: "nats://localhost:4443" }); + js = nc.jetstream(); + jsm = await nc.jetstreamManager(); + await jsm.streams.add({ name: test_stream, subjects: [test_subj] }); + }); + afterEach(async () => { + await jsm.streams.purge(test_stream); + }); + afterAll(async () => { + await jsm.streams.delete(test_stream); + // close the connection + const done = nc.closed(); + await nc.close(); + // check if the close was OK + const err = await done; + if (err) { + console.log(`error closing:`, err); + } + }); + + it('should be able to setup reply', async () => { + const requestMessage = new Ping({}) + const replyMessage = new Pong({ additionalProperties: new Map([['test', true]]) }) + const replyCallback = jest.fn().mockReturnValue(replyMessage); + await replyToRegularReply({ onDataCallback: replyCallback, nc }); + const reply = await nc.request('ping', requestMessage.marshal()); + const decodedMsg = JSONCodec().decode(reply.data); + const msg = Pong.unmarshal(decodedMsg as any); + const expectedJson = msg.marshal(); + const actualJson = replyMessage.marshal(); + expect(expectedJson).toEqual(actualJson); + }); + + it('should be able to make request', async () => { + return new Promise(async (resolve, reject) => { + const requestMessage = new Ping({}) + const replyMessage = new Pong({ additionalProperties: new Map([['test', true]]) }) + let subscription = nc.subscribe('ping'); + (async () => { + for await (const msg of subscription) { + if (msg.reply) { + msg.respond(JSONCodec().encode(replyMessage.marshal())); + } else { + reject('expected reply') + } + } + })(); + const receivedReplyMessage = await requestToRegularRequest({ requestMessage: requestMessage, nc }) + expect(receivedReplyMessage.marshal()).toEqual(replyMessage.marshal()) + resolve(); + }); + }); + }); + }); +}); From d5fe98b18a5d97268acd3bc1232013551921ef76 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Fri, 23 May 2025 21:22:20 +0200 Subject: [PATCH 14/19] fix linter --- src/codegen/generators/typescript/payloads.ts | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/src/codegen/generators/typescript/payloads.ts b/src/codegen/generators/typescript/payloads.ts index 1cfe2c66..6e84addc 100644 --- a/src/codegen/generators/typescript/payloads.ts +++ b/src/codegen/generators/typescript/payloads.ts @@ -210,45 +210,57 @@ function renderUnionUnmarshal( } /** - * Render status code based unmarshal function for union models + * Extract status code value from union member */ -function renderUnionUnmarshalByStatusCode( - model: ConstrainedUnionModel, - renderer: TypeScriptRenderer -) { - // Check if the union has status codes information - const hasStatusCodes = model.originalInput && model.originalInput['x-modelina-has-status-codes']; +function extractStatusCodeValue(unionMember: ConstrainedMetaModel): number | null { + if (!(unionMember instanceof ConstrainedReferenceModel && unionMember.ref instanceof ConstrainedObjectModel)) { + return null; + } + + const memberOriginalInput = unionMember.ref.originalInput; + const statusCode = memberOriginalInput?.['x-modelina-status-codes']; - if (!hasStatusCodes) { - return ''; + if (!statusCode) { + return null; } - // Extract status codes from union members - const statusCodeChecks: string[] = []; - const unionMembers = model.union; + if (typeof statusCode === 'object' && statusCode.code !== undefined) { + return statusCode.code; + } + + if (typeof statusCode === 'number') { + return statusCode; + } - for (const unionMember of unionMembers) { - if (unionMember instanceof ConstrainedReferenceModel && unionMember.ref instanceof ConstrainedObjectModel) { - const memberOriginalInput = unionMember.ref.originalInput; - if (memberOriginalInput && memberOriginalInput['x-modelina-status-codes']) { - const statusCode = memberOriginalInput['x-modelina-status-codes']; - - let codeValue: number; - if (typeof statusCode === 'object' && statusCode.code !== undefined) { - codeValue = statusCode.code; - } else if (typeof statusCode === 'number') { - codeValue = statusCode; - } else { - continue; // Skip invalid status codes - } - - statusCodeChecks.push(` if (statusCode === ${codeValue}) { + return null; +} + +/** + * Generate status code check string for a union member + */ +function generateStatusCodeCheck(unionMember: ConstrainedMetaModel, codeValue: number): string { + return ` if (statusCode === ${codeValue}) { return ${unionMember.type}.unmarshal(json); - }`); - } - } + }`; +} + +/** + * Render status code based unmarshal function for union models + */ +function renderUnionUnmarshalByStatusCode( + model: ConstrainedUnionModel +) { + if (!model.originalInput?.['x-modelina-has-status-codes']) { + return ''; } + const statusCodeChecks = model.union + .map(unionMember => { + const codeValue = extractStatusCodeValue(unionMember); + return codeValue !== null ? generateStatusCodeCheck(unionMember, codeValue) : null; + }) + .filter(check => check !== null); + if (statusCodeChecks.length === 0) { return ''; } @@ -393,7 +405,7 @@ export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOp } ${renderUnionUnmarshal(model, renderer)} ${renderUnionMarshal(model)} -${renderUnionUnmarshalByStatusCode(model, renderer)}`; +${renderUnionUnmarshalByStatusCode(model)}`; } return content; } From e7cce7ace06e16f96fffe44961898b82857df4d4 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 24 May 2025 18:23:59 +0200 Subject: [PATCH 15/19] wip --- docs/generators/channels.md | 5 +- .../channels/protocols/http/fetch.ts | 39 +++- .../__snapshots__/channels.spec.ts.snap | 195 +++++++++--------- test/runtime/typescript/package-lock.json | 3 + test/runtime/typescript/package.json | 16 +- 5 files changed, 148 insertions(+), 110 deletions(-) diff --git a/docs/generators/channels.md b/docs/generators/channels.md index 7fad0902..08a1e560 100644 --- a/docs/generators/channels.md +++ b/docs/generators/channels.md @@ -26,7 +26,7 @@ This is supported through the following inputs: [`asyncapi`](../inputs/asyncapi. It supports the following languages; [`typescript`](#typescript) -It supports the following protocols; [`nats`](../protocols/nats.md), [`kafka`](../protocols/kafka.md), [`mqtt`](../protocols/mqtt.md), [`amqp`](../protocols/amqp.md), [`event_source`](../protocols/eventsource.md) +It supports the following protocols; [`nats`](../protocols/nats.md), [`kafka`](../protocols/kafka.md), [`mqtt`](../protocols/mqtt.md), [`amqp`](../protocols/amqp.md), [`event_source`](../protocols/eventsource.md), [`http`](../protocols/http.md) ## Options These are the available options for the `channels` generator; @@ -49,6 +49,9 @@ Depending on which protocol, these are the dependencies: - `MQTT`: https://github.com/mqttjs/MQTT.js v5 - `AMQP`: https://github.com/amqp-node/amqplib v0 - `EventSource`: `event_source_fetch`: https://github.com/Azure/fetch-event-source v2, `event_source_express`: https://github.com/expressjs/express v4 +- `HTTP`: https://github.com/node-fetch/node-fetch v2 + +NodeFetch For TypeScript what is generated is a single file that include functions to help easier interact with AsyncAPI channels. For example; diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index ba92ba6d..8999aa6a 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -386,8 +386,43 @@ export function renderHttpFetchClient({ } } - const data = await response.json(); - return ${replyMessageModule ? `${replyMessageModule}.unmarshalByStatusCode(data, response.status)` : `${replyMessageType}.unmarshal(data)`}; + // Handle error status codes before attempting to parse JSON + if (!response.ok) { + // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing + // Only throw standardized errors for simple responses or when JSON parsing fails + ${replyMessageModule ? '' : `// Handle common HTTP error codes with standardized messages + if (response.status === 401) { + return Promise.reject(new Error('Unauthorized')); + } else if (response.status === 403) { + return Promise.reject(new Error('Forbidden')); + } else if (response.status === 404) { + return Promise.reject(new Error('Not Found')); + } else if (response.status === 500) { + return Promise.reject(new Error('Internal Server Error')); + } else { + return Promise.reject(new Error(\`HTTP Error: \${response.status} \${response.statusText}\`)); + }`} + } + + ${replyMessageModule ? `// For multi-status responses, always try to parse JSON and let unmarshalByStatusCode handle it + try { + const data = await response.json(); + return ${replyMessageModule}.unmarshalByStatusCode(data, response.status); + } catch (error) { + // If JSON parsing fails or unmarshalByStatusCode fails, provide standardized error messages + if (response.status === 401) { + return Promise.reject(new Error('Unauthorized')); + } else if (response.status === 403) { + return Promise.reject(new Error('Forbidden')); + } else if (response.status === 404) { + return Promise.reject(new Error('Not Found')); + } else if (response.status === 500) { + return Promise.reject(new Error('Internal Server Error')); + } else { + return Promise.reject(new Error(\`HTTP Error: \${response.status} \${response.statusText}\`)); + } + }` : `const data = await response.json(); + return ${replyMessageType}.unmarshal(data);`} }`; return { messageType, diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index d7389491..e84200b1 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -1158,48 +1158,7 @@ http_client: { if (!parsedContext.oauth2.onImplicitRedirect) { return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler')); } - } - - // OAuth2 Client Credentials flow validation - if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') { - if (!parsedContext.oauth2.tokenUrl) { - return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl')); - } - if (!parsedContext.oauth2.clientId) { - return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId')); - } - } - - // OAuth2 Password flow validation - if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') { - if (!parsedContext.oauth2.tokenUrl) { - return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl')); - } - if (!parsedContext.oauth2.clientId) { - return Promise.reject(new Error('OAuth2 Password flow requires clientId')); - } - if (!parsedContext.oauth2.username) { - return Promise.reject(new Error('OAuth2 Password flow requires username')); - } - if (!parsedContext.oauth2.password) { - return Promise.reject(new Error('OAuth2 Password flow requires password')); - } - } - - const headers = { - 'Content-Type': 'application/json', - ...parsedContext.additionalHeaders - }; - let url = \`\${parsedContext.server}\${parsedContext.path}\`; - - let body: any; - - - // Handle different authentication methods - if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) { - // OAuth2 authentication with existing access token - headers["Authorization"] = \`Bearer \${parsedContext.oauth2.accessToken}\`; - } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) { + // Build the authorization URL for implicit flow const authUrl = new URL(parsedContext.oauth2.authorizationUrl); authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId); @@ -1219,36 +1178,17 @@ http_client: { // Since we've initiated a redirect flow, we can't continue with the request // The application will need to handle the redirect and subsequent token extraction return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated')); - } else if (parsedContext.bearerToken) { - // bearer authentication - headers["Authorization"] = \`Bearer \${parsedContext.bearerToken}\`; - } else if (parsedContext.username && parsedContext.password) { - // basic authentication - const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64'); - headers["Authorization"] = \`Basic \${credentials}\`; - } - - // API Key Authentication - if (parsedContext.apiKey) { - if (parsedContext.apiKeyIn === 'header') { - // Add API key to headers - headers[parsedContext.apiKeyName] = parsedContext.apiKey; - } else if (parsedContext.apiKeyIn === 'query') { - // Add API key to query parameters - const separator = url.includes('?') ? '&' : '?'; - url = \`\${url}\${separator}\${parsedContext.apiKeyName}=\${encodeURIComponent(parsedContext.apiKey)}\`; - } } - // Make the API request - const response = await parsedContext.makeRequestCallback({url, - method: 'GET', - headers, - body - }); - - // Handle OAuth2 Client Credentials flow - if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) { + // OAuth2 Client Credentials flow validation and execution + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') { + if (!parsedContext.oauth2.tokenUrl) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId')); + } + try { const params = new URLSearchParams({ grant_type: 'client_credentials', @@ -1293,24 +1233,13 @@ http_client: { expiresIn: tokenData.expires_in }; - // Update headers with the new token - headers["Authorization"] = \`Bearer \${tokens.accessToken}\`; + // Update the oauth2 context with the new token + parsedContext.oauth2.accessToken = tokens.accessToken; // Notify the client about the tokens if (parsedContext.oauth2.onTokenRefresh) { parsedContext.oauth2.onTokenRefresh(tokens); } - - // Retry the original request with the new token - const retryResponse = await parsedContext.makeRequestCallback({ - url, - method: "GET", - headers, - body - }); - - const data = await retryResponse.json(); - return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); } else { return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`)); } @@ -1320,8 +1249,21 @@ http_client: { } } - // Handle OAuth2 password flow - if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) { + // OAuth2 Password flow validation and execution + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') { + if (!parsedContext.oauth2.tokenUrl) { + return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Password flow requires clientId')); + } + if (!parsedContext.oauth2.username) { + return Promise.reject(new Error('OAuth2 Password flow requires username')); + } + if (!parsedContext.oauth2.password) { + return Promise.reject(new Error('OAuth2 Password flow requires password')); + } + try { const params = new URLSearchParams({ grant_type: 'password', @@ -1354,25 +1296,13 @@ http_client: { expiresIn: tokenData.expires_in }; - // Update headers with the new token - headers["Authorization"] = \`Bearer \${tokens.accessToken}\`; + // Update the oauth2 context with the new token + parsedContext.oauth2.accessToken = tokens.accessToken; // Notify the client about the tokens if (parsedContext.oauth2.onTokenRefresh) { parsedContext.oauth2.onTokenRefresh(tokens); } - - // Retry the original request with the new token - const retryResponse = await parsedContext.makeRequestCallback({ - url, - method: "GET", - headers, - body - }); - - const data = await retryResponse.json(); - return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); - } else { return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`)); } @@ -1382,6 +1312,47 @@ http_client: { } } + const headers = { + 'Content-Type': 'application/json', + ...parsedContext.additionalHeaders + }; + let url = \`\${parsedContext.server}\${parsedContext.path}\`; + + let body: any; + + + // Handle different authentication methods + if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) { + // OAuth2 authentication with existing access token + headers["Authorization"] = \`Bearer \${parsedContext.oauth2.accessToken}\`; + } else if (parsedContext.bearerToken) { + // bearer authentication + headers["Authorization"] = \`Bearer \${parsedContext.bearerToken}\`; + } else if (parsedContext.username && parsedContext.password) { + // basic authentication + const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64'); + headers["Authorization"] = \`Basic \${credentials}\`; + } + + // API Key Authentication + if (parsedContext.apiKey) { + if (parsedContext.apiKeyIn === 'header') { + // Add API key to headers + headers[parsedContext.apiKeyName] = parsedContext.apiKey; + } else if (parsedContext.apiKeyIn === 'query') { + // Add API key to query parameters + const separator = url.includes('?') ? '&' : '?'; + url = \`\${url}\${separator}\${parsedContext.apiKeyName}=\${encodeURIComponent(parsedContext.apiKey)}\`; + } + } + + // Make the API request + const response = await parsedContext.makeRequestCallback({url, + method: 'GET', + headers, + body + }); + // Handle token refresh for OAuth2 if we get a 401 if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) { try { @@ -1422,6 +1393,17 @@ http_client: { body }); + // Check if the retry response is successful + if (!retryResponse.ok) { + if (retryResponse.status === 401) { + return Promise.reject(new Error('Unauthorized')); + } else if (retryResponse.status === 403) { + return Promise.reject(new Error('Forbidden')); + } else if (retryResponse.status >= 400) { + return Promise.reject(new Error(\`Request failed with status \${retryResponse.status}: \${retryResponse.statusText}\`)); + } + } + const data = await retryResponse.json(); return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); } else { @@ -1435,6 +1417,21 @@ http_client: { } } + // Handle error status codes for all authentication types + if (!response.ok) { + if (response.status === 401) { + return Promise.reject(new Error('Unauthorized')); + } else if (response.status === 403) { + return Promise.reject(new Error('Forbidden')); + } else if (response.status === 404) { + return Promise.reject(new Error('Not Found')); + } else if (response.status >= 500) { + return Promise.reject(new Error(\`Server Error: \${response.status} \${response.statusText}\`)); + } else if (response.status >= 400) { + return Promise.reject(new Error(\`Client Error: \${response.status} \${response.statusText}\`)); + } + } + const data = await response.json(); return MessageTypeModule.unmarshalByStatusCode(data, response.status); } diff --git a/test/runtime/typescript/package-lock.json b/test/runtime/typescript/package-lock.json index 8c16bd31..14ce08be 100644 --- a/test/runtime/typescript/package-lock.json +++ b/test/runtime/typescript/package-lock.json @@ -10,16 +10,19 @@ "@swc/jest": "^0.2.23", "ajv": "^8.17.1", "amqplib": "^0.10.5", + "body-parser": "^1.20.2", "express": "^4.21.0", "isomorphic-fetch": "^3.0.0", "jest": "^27.2.5", "kafkajs": "^2.2.4", "mqtt": "^5.10.3", "nats": "^2.26.0", + "node-fetch": "^2.6.7", "ts-jest": "^27.0.5" }, "devDependencies": { "@types/amqplib": "^0.10.6", + "@types/body-parser": "^1.19.5", "@types/express": "^4.17.21", "@types/node-fetch": "^2.6.12", "jest-fetch-mock": "^3.0.3" diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json index 53342725..9e560acd 100644 --- a/test/runtime/typescript/package.json +++ b/test/runtime/typescript/package.json @@ -1,16 +1,16 @@ { "scripts": { "test": "npm run test:kafka && npm run test:nats && npm run test:mqtt && npm run test:amqp && npm run test:eventsource && npm run test:http", - "test:kafka": "jest -- ./test/channels/kafka.spec.ts", + "test:kafka": "jest -- ./test/channels/regular/kafka.spec.ts", "test:nats": "npm run test:nats:client && npm run test:nats:channels", - "test:nats:channels": "jest -- ./test/channels/nats.spec.ts", + "test:nats:channels": "jest -- ./test/channels/**/nats.spec.ts", "test:nats:client": "jest -- ./test/client/nats.spec.ts", - "test:mqtt": "jest -- ./test/channels/mqtt.spec.ts", - "test:amqp": "jest -- ./test/channels/amqp.spec.ts", - "test:eventsource": "jest -- ./test/channels/eventsource.spec.ts", - "test:http": "jest -- ./test/channels/http_client/http_client.spec.ts ./test/channels/http_client/api_auth.spec.ts ./test/channels/http_client/oauth2_client_credentials.spec.ts ./test/channels/http_client/oauth2_implicit_flow.spec.ts ./test/channels/http_client/oauth2_password_flow.spec.ts ./test/channels/http_client/oauth2_refresh_token.spec.ts", - "test:http:basic": "jest -- ./test/channels/http_client/basic_http_methods.spec.ts", - "test:http:auth": "jest -- ./test/channels/http_client/api_auth.spec.ts", + "test:mqtt": "jest -- ./test/channels/regular/mqtt.spec.ts", + "test:amqp": "jest -- ./test/channels/regular/amqp.spec.ts", + "test:eventsource": "jest -- ./test/channels/regular/eventsource.spec.ts", + "test:http": "jest -- ./test/channels/request_reply/http_client/http_client.spec.ts ./test/channels/request_reply/http_client/api_auth.spec.ts ./test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts ./test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts ./test/channels/request_reply/http_client/oauth2_password_flow.spec.ts ./test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts", + "test:http:basic": "jest -- ./test/channels/request_reply/http_client/basic_http_methods.spec.ts", + "test:http:auth": "jest -- ./test/channels/request_reply/http_client/api_auth.spec.ts", "test:http:oauth2": "jest -- ./test/channels/http_client/oauth2_client_credentials.spec.ts ./test/channels/http_client/oauth2_implicit_flow.spec.ts ./test/channels/http_client/oauth2_password_flow.spec.ts ./test/channels/http_client/oauth2_refresh_token.spec.ts", "generate": "npm run generate:regular && npm run generate:request:reply", "generate:request:reply": "node ../../../bin/run.mjs generate ./codegen-request-reply.mjs", From de2ce9f030bfb250e11b781222ddcfbe3bfa6e37 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 24 May 2025 18:25:02 +0200 Subject: [PATCH 16/19] wip --- .../__snapshots__/channels.spec.ts.snap | 204 ++++++++++-------- 1 file changed, 115 insertions(+), 89 deletions(-) diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index e84200b1..7107c4f8 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -1158,7 +1158,48 @@ http_client: { if (!parsedContext.oauth2.onImplicitRedirect) { return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler')); } - + } + + // OAuth2 Client Credentials flow validation + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') { + if (!parsedContext.oauth2.tokenUrl) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId')); + } + } + + // OAuth2 Password flow validation + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') { + if (!parsedContext.oauth2.tokenUrl) { + return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl')); + } + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Password flow requires clientId')); + } + if (!parsedContext.oauth2.username) { + return Promise.reject(new Error('OAuth2 Password flow requires username')); + } + if (!parsedContext.oauth2.password) { + return Promise.reject(new Error('OAuth2 Password flow requires password')); + } + } + + const headers = { + 'Content-Type': 'application/json', + ...parsedContext.additionalHeaders + }; + let url = \`\${parsedContext.server}\${parsedContext.path}\`; + + let body: any; + + + // Handle different authentication methods + if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) { + // OAuth2 authentication with existing access token + headers["Authorization"] = \`Bearer \${parsedContext.oauth2.accessToken}\`; + } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) { // Build the authorization URL for implicit flow const authUrl = new URL(parsedContext.oauth2.authorizationUrl); authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId); @@ -1178,17 +1219,36 @@ http_client: { // Since we've initiated a redirect flow, we can't continue with the request // The application will need to handle the redirect and subsequent token extraction return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated')); + } else if (parsedContext.bearerToken) { + // bearer authentication + headers["Authorization"] = \`Bearer \${parsedContext.bearerToken}\`; + } else if (parsedContext.username && parsedContext.password) { + // basic authentication + const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64'); + headers["Authorization"] = \`Basic \${credentials}\`; } - - // OAuth2 Client Credentials flow validation and execution - if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') { - if (!parsedContext.oauth2.tokenUrl) { - return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl')); - } - if (!parsedContext.oauth2.clientId) { - return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId')); + + // API Key Authentication + if (parsedContext.apiKey) { + if (parsedContext.apiKeyIn === 'header') { + // Add API key to headers + headers[parsedContext.apiKeyName] = parsedContext.apiKey; + } else if (parsedContext.apiKeyIn === 'query') { + // Add API key to query parameters + const separator = url.includes('?') ? '&' : '?'; + url = \`\${url}\${separator}\${parsedContext.apiKeyName}=\${encodeURIComponent(parsedContext.apiKey)}\`; } - + } + + // Make the API request + const response = await parsedContext.makeRequestCallback({url, + method: 'GET', + headers, + body + }); + + // Handle OAuth2 Client Credentials flow + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) { try { const params = new URLSearchParams({ grant_type: 'client_credentials', @@ -1233,13 +1293,24 @@ http_client: { expiresIn: tokenData.expires_in }; - // Update the oauth2 context with the new token - parsedContext.oauth2.accessToken = tokens.accessToken; + // Update headers with the new token + headers["Authorization"] = \`Bearer \${tokens.accessToken}\`; // Notify the client about the tokens if (parsedContext.oauth2.onTokenRefresh) { parsedContext.oauth2.onTokenRefresh(tokens); } + + // Retry the original request with the new token + const retryResponse = await parsedContext.makeRequestCallback({ + url, + method: "GET", + headers, + body + }); + + const data = await retryResponse.json(); + return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); } else { return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`)); } @@ -1249,21 +1320,8 @@ http_client: { } } - // OAuth2 Password flow validation and execution - if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') { - if (!parsedContext.oauth2.tokenUrl) { - return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl')); - } - if (!parsedContext.oauth2.clientId) { - return Promise.reject(new Error('OAuth2 Password flow requires clientId')); - } - if (!parsedContext.oauth2.username) { - return Promise.reject(new Error('OAuth2 Password flow requires username')); - } - if (!parsedContext.oauth2.password) { - return Promise.reject(new Error('OAuth2 Password flow requires password')); - } - + // Handle OAuth2 password flow + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) { try { const params = new URLSearchParams({ grant_type: 'password', @@ -1296,13 +1354,25 @@ http_client: { expiresIn: tokenData.expires_in }; - // Update the oauth2 context with the new token - parsedContext.oauth2.accessToken = tokens.accessToken; + // Update headers with the new token + headers["Authorization"] = \`Bearer \${tokens.accessToken}\`; // Notify the client about the tokens if (parsedContext.oauth2.onTokenRefresh) { parsedContext.oauth2.onTokenRefresh(tokens); } + + // Retry the original request with the new token + const retryResponse = await parsedContext.makeRequestCallback({ + url, + method: "GET", + headers, + body + }); + + const data = await retryResponse.json(); + return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); + } else { return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`)); } @@ -1312,47 +1382,6 @@ http_client: { } } - const headers = { - 'Content-Type': 'application/json', - ...parsedContext.additionalHeaders - }; - let url = \`\${parsedContext.server}\${parsedContext.path}\`; - - let body: any; - - - // Handle different authentication methods - if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) { - // OAuth2 authentication with existing access token - headers["Authorization"] = \`Bearer \${parsedContext.oauth2.accessToken}\`; - } else if (parsedContext.bearerToken) { - // bearer authentication - headers["Authorization"] = \`Bearer \${parsedContext.bearerToken}\`; - } else if (parsedContext.username && parsedContext.password) { - // basic authentication - const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64'); - headers["Authorization"] = \`Basic \${credentials}\`; - } - - // API Key Authentication - if (parsedContext.apiKey) { - if (parsedContext.apiKeyIn === 'header') { - // Add API key to headers - headers[parsedContext.apiKeyName] = parsedContext.apiKey; - } else if (parsedContext.apiKeyIn === 'query') { - // Add API key to query parameters - const separator = url.includes('?') ? '&' : '?'; - url = \`\${url}\${separator}\${parsedContext.apiKeyName}=\${encodeURIComponent(parsedContext.apiKey)}\`; - } - } - - // Make the API request - const response = await parsedContext.makeRequestCallback({url, - method: 'GET', - headers, - body - }); - // Handle token refresh for OAuth2 if we get a 401 if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) { try { @@ -1393,17 +1422,6 @@ http_client: { body }); - // Check if the retry response is successful - if (!retryResponse.ok) { - if (retryResponse.status === 401) { - return Promise.reject(new Error('Unauthorized')); - } else if (retryResponse.status === 403) { - return Promise.reject(new Error('Forbidden')); - } else if (retryResponse.status >= 400) { - return Promise.reject(new Error(\`Request failed with status \${retryResponse.status}: \${retryResponse.statusText}\`)); - } - } - const data = await retryResponse.json(); return MessageTypeModule.unmarshalByStatusCode(data, retryResponse.status); } else { @@ -1417,23 +1435,31 @@ http_client: { } } - // Handle error status codes for all authentication types + // Handle error status codes before attempting to parse JSON if (!response.ok) { + // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing + // Only throw standardized errors for simple responses or when JSON parsing fails + + } + + // For multi-status responses, always try to parse JSON and let unmarshalByStatusCode handle it + try { + const data = await response.json(); + return MessageTypeModule.unmarshalByStatusCode(data, response.status); + } catch (error) { + // If JSON parsing fails or unmarshalByStatusCode fails, provide standardized error messages if (response.status === 401) { return Promise.reject(new Error('Unauthorized')); } else if (response.status === 403) { return Promise.reject(new Error('Forbidden')); } else if (response.status === 404) { return Promise.reject(new Error('Not Found')); - } else if (response.status >= 500) { - return Promise.reject(new Error(\`Server Error: \${response.status} \${response.statusText}\`)); - } else if (response.status >= 400) { - return Promise.reject(new Error(\`Client Error: \${response.status} \${response.statusText}\`)); + } else if (response.status === 500) { + return Promise.reject(new Error('Internal Server Error')); + } else { + return Promise.reject(new Error(\`HTTP Error: \${response.status} \${response.statusText}\`)); } } - - const data = await response.json(); - return MessageTypeModule.unmarshalByStatusCode(data, response.status); } }};" `; From 052c836add7ae812e934e4e400470bca42d0f551 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 24 May 2025 18:49:38 +0200 Subject: [PATCH 17/19] wip --- docs/README.md | 1 + docs/generators/channels.md | 2 +- docs/inputs/asyncapi.md | 14 +++++++++++++ docs/protocols/http.md | 41 ------------------------------------- 4 files changed, 16 insertions(+), 42 deletions(-) delete mode 100644 docs/protocols/http.md diff --git a/docs/README.md b/docs/README.md index 393ba2dc..ef9ea565 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,6 +51,7 @@ Each protocol has its own limitations, corner cases, and features; thus, each ha - [Kafka](./protocols/kafka.md) - [MQTT](./protocols/mqtt.md) - [EventSource](./protocols/eventsource.md) +- [HTTP Client](./protocols/http_client.md) ### [Migrations](./contributing.md) Get an overview of how to contribute to the project diff --git a/docs/generators/channels.md b/docs/generators/channels.md index 08a1e560..55f55526 100644 --- a/docs/generators/channels.md +++ b/docs/generators/channels.md @@ -26,7 +26,7 @@ This is supported through the following inputs: [`asyncapi`](../inputs/asyncapi. It supports the following languages; [`typescript`](#typescript) -It supports the following protocols; [`nats`](../protocols/nats.md), [`kafka`](../protocols/kafka.md), [`mqtt`](../protocols/mqtt.md), [`amqp`](../protocols/amqp.md), [`event_source`](../protocols/eventsource.md), [`http`](../protocols/http.md) +It supports the following protocols; [`nats`](../protocols/nats.md), [`kafka`](../protocols/kafka.md), [`mqtt`](../protocols/mqtt.md), [`amqp`](../protocols/amqp.md), [`event_source`](../protocols/eventsource.md), [`http_client`](../protocols/http_client.md) ## Options These are the available options for the `channels` generator; diff --git a/docs/inputs/asyncapi.md b/docs/inputs/asyncapi.md index 0ea069e3..b3e6af00 100644 --- a/docs/inputs/asyncapi.md +++ b/docs/inputs/asyncapi.md @@ -56,6 +56,20 @@ To customize the code generation through the AsyncAPI document, use the `x-the-c } ``` +## Protocol support + +### Kafka + +### HTTP Client + +### MQTT + +### NATS + +### AMQP + +### EventSource + ## FAQ ### How does it relate to AsyncAPI Generator and templates? diff --git a/docs/protocols/http.md b/docs/protocols/http.md deleted file mode 100644 index 198580e9..00000000 --- a/docs/protocols/http.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -sidebar_position: 99 ---- - -# HTTP(S) - -Both client and server generator is available. - -It is currently available through the generators ([channels](../generators/channels.md)): - -All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP `method` binding for operation and `statusCode` for messages. - -## TypeScript - -| **Feature** | Client | Server | -|---|---|---| -| Download | ➗ | ➗ | -| Upload | ➗ | ➗ | -| Offset based Pagination | ➗ | ➗ | -| Cursor based Pagination | ➗ | ➗ | -| Page based Pagination | ➗ | ➗ | -| Time based Pagination | ➗ | ➗ | -| Keyset based Pagination | ➗ | ➗ | -| Retry with backoff | ➗ | ➗ | -| OAuth2 Authorization code | ✔️ | ➗ | -| OAuth2 Implicit | ✔️ | ➗ | -| OAuth2 password | ✔️ | ➗ | -| OAuth2 Client Credentials | ✔️ | ➗ | -| Username/password Authentication | ✔️ | ➗ | -| Bearer Authentication | ✔️ | ➗ | -| Basic Authentication | ✔️ | ➗ | -| API Key Authentication | ✔️ | ➗ | -| XML Based API | ➗ | ➗ | -| JSON Based API | ✔️ | ➗ | -| POST | ✔️ | ➗ | -| GET | ✔️ | ➗ | -| PATCH | ✔️ | ➗ | -| DELETE | ✔️ | ➗ | -| PUT | ✔️ | ➗ | -| HEAD | ✔️ | ➗ | -| OPTIONS | ✔️ | ➗ | \ No newline at end of file From fdf5c17a43c08a08761ec0602fda2f263a4e3c69 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 24 May 2025 18:55:51 +0200 Subject: [PATCH 18/19] update docs --- docs/inputs/asyncapi.md | 860 +++++++++++++++++++++++++++++++++- docs/protocols/http_client.md | 41 ++ 2 files changed, 886 insertions(+), 15 deletions(-) create mode 100644 docs/protocols/http_client.md diff --git a/docs/inputs/asyncapi.md b/docs/inputs/asyncapi.md index b3e6af00..0ca2ec8b 100644 --- a/docs/inputs/asyncapi.md +++ b/docs/inputs/asyncapi.md @@ -15,61 +15,882 @@ The Codegen Project was started because of a need for a code generator that; There is a lot of overlap with existing tooling, however the idea is to form the same level of quality that the OpenAPI Generator provides to OpenAPI community for HTTP, for AsyncAPI and **any** protocol (including HTTP), and the usability of the Apollo GraphQL generator. How are we gonna achieve it? Together, and a [roadmap](https://github.com/orgs/the-codegen-project/projects/1/views/2). -Enabled extensions: +## Basic AsyncAPI Document Structure + +Here's a complete basic AsyncAPI document example to get you started: + +```json +{ + "asyncapi": "3.0.0", + "info": { + "title": "User Service API", + "version": "1.0.0", + "description": "API for user management events" + }, + "channels": { + "userSignedup": { + "address": "user/signedup/{userId}/{region}", + "parameters": { + "userId": { + "description": "The unique identifier for the user" + }, + "region": { + "description": "The geographic region", + "enum": ["us-east", "us-west", "eu-central"] + } + }, + "messages": { + "UserSignedUp": { + "$ref": "#/components/messages/UserSignedUp" + } + } + } + }, + "operations": { + "sendUserSignedup": { + "action": "send", + "channel": { + "$ref": "#/channels/userSignedup" + }, + "messages": [ + { + "$ref": "#/channels/userSignedup/messages/UserSignedUp" + } + ] + }, + "receiveUserSignedup": { + "action": "receive", + "channel": { + "$ref": "#/channels/userSignedup" + }, + "messages": [ + { + "$ref": "#/channels/userSignedup/messages/UserSignedUp" + } + ] + } + }, + "components": { + "messages": { + "UserSignedUp": { + "payload": { + "$ref": "#/components/schemas/UserSignedUpPayload" + }, + "headers": { + "$ref": "#/components/schemas/UserHeaders" + } + } + }, + "schemas": { + "UserSignedUpPayload": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "description": "Name of the user" + }, + "email": { + "type": "string", + "format": "email", + "description": "Email of the user" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the user was created" + } + }, + "required": ["display_name", "email"] + }, + "UserHeaders": { + "type": "object", + "properties": { + "correlation_id": { + "type": "string", + "description": "Correlation ID for tracking" + }, + "source": { + "type": "string", + "description": "Source system" + } + } + } + } + } +} +``` + +## Extensions + To customize the code generation through the AsyncAPI document, use the `x-the-codegen-project` [extension object](https://www.asyncapi.com/docs/reference/specification/v3.0.0#specificationExtensions) with the following properties: -### Channel +### Channel Extensions `channelName`, string, customize the name of the functions generated for the channel, use this to overwrite the automatically determined name for models and functions. This will be used by the following generators; [payloads](../generators/payloads.md), [parameters](../generators/parameters.md) and [channels](../generators/channels.md). + `functionTypeMapping`, [ChannelFunctionTypes](https://the-codegen-project.org/docs/api/enumerations/ChannelFunctionTypes), customize which generators to generate for the given channel, use this to specify further which functions we render. This will be used by the following generators; [channels](../generators/channels.md). +#### Example: Custom Channel Configuration + ```json { "asyncapi": "3.0.0", - ..., + "info": { + "title": "Custom Channel Example", + "version": "1.0.0" + }, "channels": { - "test-channel": { + "user-events": { + "address": "events/user/{action}", + "parameters": { + "action": { + "enum": ["created", "updated", "deleted"] + } + }, + "messages": { + "UserEvent": { + "payload": { + "type": "object", + "properties": { + "userId": {"type": "string"}, + "action": {"type": "string"}, + "timestamp": {"type": "string", "format": "date-time"} + } + } + } + }, "x-the-codegen-project": { - "channelName": "Test", - "functionTypeMapping": ["event_source_express"] + "channelName": "UserEventChannel", + "functionTypeMapping": ["event_source_express", "kafka_publish"] } } } } ``` -### Operation +### Operation Extensions -`functionTypeMapping`, [ChannelFunctionTypes](https://the-codegen-project.org/docs/api/enumerations/ChannelFunctionTypes), customize which generators to generate for the given operator, use this to specify further which functions we render. This will be used by the following generators; [channels](../generators/channels.md). +`functionTypeMapping`, [ChannelFunctionTypes](https://the-codegen-project.org/docs/api/enumerations/ChannelFunctionTypes), customize which generators to generate for the given operation, use this to specify further which functions we render. This will be used by the following generators; [channels](../generators/channels.md). + +#### Example: Custom Operation Configuration ```json { "asyncapi": "3.0.0", - ..., - "operation": { - "test-operation": { + "info": { + "title": "Custom Operation Example", + "version": "1.0.0" + }, + "operations": { + "publishUserEvent": { + "action": "send", + "channel": { + "$ref": "#/channels/user-events" + }, + "messages": [ + {"$ref": "#/channels/user-events/messages/UserEvent"} + ], "x-the-codegen-project": { - "functionTypeMapping": ["event_source_express"] + "functionTypeMapping": ["kafka_publish"] + } + }, + "subscribeToUserEvents": { + "action": "receive", + "channel": { + "$ref": "#/channels/user-events" + }, + "messages": [ + {"$ref": "#/channels/user-events/messages/UserEvent"} + ], + "x-the-codegen-project": { + "functionTypeMapping": ["kafka_subscribe"] } } } } ``` -## Protocol support +## Protocol Support + +### HTTP Client + +Use HTTP bindings to generate HTTP client code. Supports all standard HTTP methods and status codes. + +#### Example: REST API with Multiple HTTP Methods + +```json +{ + "asyncapi": "3.0.0", + "info": { + "title": "User Management API", + "version": "1.0.0" + }, + "channels": { + "users": { + "address": "/users/{userId}", + "parameters": { + "userId": { + "description": "User identifier" + } + }, + "messages": { + "UserRequest": { + "payload": { + "$ref": "#/components/schemas/User" + } + }, + "UserResponse": { + "payload": { + "$ref": "#/components/schemas/User" + }, + "bindings": { + "http": { + "statusCode": 200 + } + } + }, + "NotFound": { + "payload": { + "type": "object", + "properties": { + "error": {"type": "string"}, + "code": {"type": "string"} + } + }, + "bindings": { + "http": { + "statusCode": 404 + } + } + } + } + } + }, + "operations": { + "createUser": { + "action": "send", + "channel": {"$ref": "#/channels/users"}, + "messages": [{"$ref": "#/channels/users/messages/UserRequest"}], + "bindings": { + "http": {"method": "POST"} + }, + "reply": { + "channel": {"$ref": "#/channels/users"}, + "messages": [{"$ref": "#/channels/users/messages/UserResponse"}] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "getUser": { + "action": "send", + "channel": {"$ref": "#/channels/users"}, + "messages": [], + "bindings": { + "http": {"method": "GET"} + }, + "reply": { + "channel": {"$ref": "#/channels/users"}, + "messages": [ + {"$ref": "#/channels/users/messages/UserResponse"}, + {"$ref": "#/channels/users/messages/NotFound"} + ] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "updateUser": { + "action": "send", + "channel": {"$ref": "#/channels/users"}, + "messages": [{"$ref": "#/channels/users/messages/UserRequest"}], + "bindings": { + "http": {"method": "PUT"} + }, + "reply": { + "channel": {"$ref": "#/channels/users"}, + "messages": [{"$ref": "#/channels/users/messages/UserResponse"}] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + }, + "deleteUser": { + "action": "send", + "channel": {"$ref": "#/channels/users"}, + "messages": [], + "bindings": { + "http": {"method": "DELETE"} + }, + "reply": { + "channel": {"$ref": "#/channels/users"}, + "messages": [{"$ref": "#/channels/users/messages/UserResponse"}] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["http_client"] + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "created_at": {"type": "string", "format": "date-time"} + }, + "required": ["name", "email"] + } + } + } +} +``` ### Kafka -### HTTP Client +Generate Kafka producers and consumers with proper serialization. -### MQTT +#### Example: Kafka Event Streaming + +```json +{ + "asyncapi": "3.0.0", + "info": { + "title": "Order Processing Events", + "version": "1.0.0" + }, + "channels": { + "order-events": { + "address": "orders.{eventType}.{region}", + "parameters": { + "eventType": { + "enum": ["created", "updated", "cancelled", "completed"] + }, + "region": { + "enum": ["us", "eu", "asia"] + } + }, + "messages": { + "OrderEvent": { + "payload": { + "$ref": "#/components/schemas/OrderEvent" + }, + "headers": { + "$ref": "#/components/schemas/EventHeaders" + } + } + } + } + }, + "operations": { + "publishOrderEvent": { + "action": "send", + "channel": {"$ref": "#/channels/order-events"}, + "messages": [{"$ref": "#/channels/order-events/messages/OrderEvent"}], + "bindings": { + "kafka": { + "clientId": "order-service", + "groupId": "order-processors" + } + }, + "x-the-codegen-project": { + "functionTypeMapping": ["kafka_publish"] + } + }, + "subscribeToOrderEvents": { + "action": "receive", + "channel": {"$ref": "#/channels/order-events"}, + "messages": [{"$ref": "#/channels/order-events/messages/OrderEvent"}], + "bindings": { + "kafka": { + "groupId": "order-processors", + "clientId": "order-consumer" + } + }, + "x-the-codegen-project": { + "functionTypeMapping": ["kafka_subscribe"] + } + } + }, + "components": { + "schemas": { + "OrderEvent": { + "type": "object", + "properties": { + "orderId": {"type": "string"}, + "customerId": {"type": "string"}, + "amount": {"type": "number"}, + "currency": {"type": "string"}, + "status": {"type": "string"}, + "timestamp": {"type": "string", "format": "date-time"} + }, + "required": ["orderId", "customerId", "amount", "status"] + }, + "EventHeaders": { + "type": "object", + "properties": { + "correlationId": {"type": "string"}, + "source": {"type": "string"}, + "version": {"type": "string"} + } + } + } + } +} +``` ### NATS +Generate NATS request/reply patterns and pub/sub functionality. + +#### Example: NATS Request-Reply Pattern + +```json +{ + "asyncapi": "3.0.0", + "info": { + "title": "User Service NATS API", + "version": "1.0.0" + }, + "channels": { + "user-service": { + "address": "user.service.{operation}", + "parameters": { + "operation": { + "enum": ["get", "create", "update", "delete"] + } + }, + "messages": { + "UserRequest": { + "payload": { + "$ref": "#/components/schemas/UserRequest" + } + }, + "UserResponse": { + "payload": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + } + }, + "operations": { + "requestUserOperation": { + "action": "send", + "channel": {"$ref": "#/channels/user-service"}, + "messages": [{"$ref": "#/channels/user-service/messages/UserRequest"}], + "reply": { + "channel": {"$ref": "#/channels/user-service"}, + "messages": [{"$ref": "#/channels/user-service/messages/UserResponse"}] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["nats_request"] + } + }, + "replyToUserOperation": { + "action": "receive", + "channel": {"$ref": "#/channels/user-service"}, + "messages": [{"$ref": "#/channels/user-service/messages/UserRequest"}], + "reply": { + "channel": {"$ref": "#/channels/user-service"}, + "messages": [{"$ref": "#/channels/user-service/messages/UserResponse"}] + }, + "x-the-codegen-project": { + "functionTypeMapping": ["nats_reply"] + } + } + }, + "components": { + "schemas": { + "UserRequest": { + "type": "object", + "properties": { + "operation": {"type": "string"}, + "userId": {"type": "string"}, + "data": {"type": "object"} + }, + "required": ["operation"] + }, + "UserResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "data": {"type": "object"}, + "error": {"type": "string"} + }, + "required": ["success"] + } + } + } +} +``` + +### MQTT + +Generate MQTT publish/subscribe clients with QoS levels. + +#### Example: IoT Device Communications + +```json +{ + "asyncapi": "3.0.0", + "info": { + "title": "IoT Device Management", + "version": "1.0.0" + }, + "channels": { + "device-telemetry": { + "address": "devices/{deviceId}/telemetry/{sensorType}", + "parameters": { + "deviceId": { + "description": "Unique device identifier" + }, + "sensorType": { + "enum": ["temperature", "humidity", "pressure", "motion"] + } + }, + "messages": { + "TelemetryData": { + "payload": { + "$ref": "#/components/schemas/TelemetryData" + } + } + } + }, + "device-commands": { + "address": "devices/{deviceId}/commands", + "parameters": { + "deviceId": { + "description": "Unique device identifier" + } + }, + "messages": { + "DeviceCommand": { + "payload": { + "$ref": "#/components/schemas/DeviceCommand" + } + } + } + } + }, + "operations": { + "publishTelemetry": { + "action": "send", + "channel": {"$ref": "#/channels/device-telemetry"}, + "messages": [{"$ref": "#/channels/device-telemetry/messages/TelemetryData"}], + "bindings": { + "mqtt": { + "qos": 1, + "retain": false + } + }, + "x-the-codegen-project": { + "functionTypeMapping": ["mqtt_publish"] + } + }, + "subscribeToTelemetry": { + "action": "receive", + "channel": {"$ref": "#/channels/device-telemetry"}, + "messages": [{"$ref": "#/channels/device-telemetry/messages/TelemetryData"}], + "bindings": { + "mqtt": { + "qos": 1 + } + }, + "x-the-codegen-project": { + "functionTypeMapping": ["mqtt_subscribe"] + } + }, + "sendCommand": { + "action": "send", + "channel": {"$ref": "#/channels/device-commands"}, + "messages": [{"$ref": "#/channels/device-commands/messages/DeviceCommand"}], + "bindings": { + "mqtt": { + "qos": 2, + "retain": true + } + }, + "x-the-codegen-project": { + "functionTypeMapping": ["mqtt_publish"] + } + } + }, + "components": { + "schemas": { + "TelemetryData": { + "type": "object", + "properties": { + "deviceId": {"type": "string"}, + "sensorType": {"type": "string"}, + "value": {"type": "number"}, + "unit": {"type": "string"}, + "timestamp": {"type": "string", "format": "date-time"}, + "location": { + "type": "object", + "properties": { + "latitude": {"type": "number"}, + "longitude": {"type": "number"} + } + } + }, + "required": ["deviceId", "sensorType", "value", "timestamp"] + }, + "DeviceCommand": { + "type": "object", + "properties": { + "command": {"type": "string"}, + "parameters": {"type": "object"}, + "commandId": {"type": "string"}, + "timestamp": {"type": "string", "format": "date-time"} + }, + "required": ["command", "commandId"] + } + } + } +} +``` + ### AMQP +Generate AMQP producers and consumers for message queuing. + +#### Example: Order Processing Queue + +```json +{ + "asyncapi": "3.0.0", + "info": { + "title": "Order Processing Queue", + "version": "1.0.0" + }, + "channels": { + "order-queue": { + "address": "orders.processing", + "messages": { + "OrderMessage": { + "payload": { + "$ref": "#/components/schemas/Order" + }, + "headers": { + "$ref": "#/components/schemas/MessageHeaders" + } + } + } + }, + "order-dlq": { + "address": "orders.dead-letter", + "messages": { + "FailedOrderMessage": { + "payload": { + "$ref": "#/components/schemas/FailedOrder" + } + } + } + } + }, + "operations": { + "publishOrder": { + "action": "send", + "channel": {"$ref": "#/channels/order-queue"}, + "messages": [{"$ref": "#/channels/order-queue/messages/OrderMessage"}], + "bindings": { + "amqp": { + "exchange": { + "name": "orders", + "type": "topic", + "durable": true + }, + "routingKey": "order.created" + } + }, + "x-the-codegen-project": { + "functionTypeMapping": ["amqp_publish"] + } + }, + "consumeOrders": { + "action": "receive", + "channel": {"$ref": "#/channels/order-queue"}, + "messages": [{"$ref": "#/channels/order-queue/messages/OrderMessage"}], + "bindings": { + "amqp": { + "queue": { + "name": "order-processing-queue", + "durable": true, + "exclusive": false, + "autoDelete": false + }, + "ack": true + } + }, + "x-the-codegen-project": { + "functionTypeMapping": ["amqp_consume"] + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "orderId": {"type": "string"}, + "customerId": {"type": "string"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "productId": {"type": "string"}, + "quantity": {"type": "integer"}, + "price": {"type": "number"} + } + } + }, + "totalAmount": {"type": "number"}, + "currency": {"type": "string"}, + "orderDate": {"type": "string", "format": "date-time"} + }, + "required": ["orderId", "customerId", "items", "totalAmount"] + }, + "FailedOrder": { + "type": "object", + "properties": { + "orderId": {"type": "string"}, + "error": {"type": "string"}, + "retryCount": {"type": "integer"}, + "failedAt": {"type": "string", "format": "date-time"} + } + }, + "MessageHeaders": { + "type": "object", + "properties": { + "messageId": {"type": "string"}, + "correlationId": {"type": "string"}, + "timestamp": {"type": "string", "format": "date-time"}, + "priority": {"type": "integer", "minimum": 0, "maximum": 255} + } + } + } + } +} +``` + ### EventSource +Generate Server-Sent Events (SSE) implementations for real-time updates. + +#### Example: Real-time Notifications + +```json +{ + "asyncapi": "3.0.0", + "info": { + "title": "Real-time Notifications", + "version": "1.0.0" + }, + "channels": { + "user-notifications": { + "address": "/events/users/{userId}/notifications", + "parameters": { + "userId": { + "description": "User identifier for targeted notifications" + } + }, + "messages": { + "Notification": { + "payload": { + "$ref": "#/components/schemas/Notification" + } + }, + "SystemAlert": { + "payload": { + "$ref": "#/components/schemas/SystemAlert" + } + } + } + }, + "live-updates": { + "address": "/events/live/{topic}", + "parameters": { + "topic": { + "enum": ["stock-prices", "sports-scores", "weather-alerts"] + } + }, + "messages": { + "LiveUpdate": { + "payload": { + "$ref": "#/components/schemas/LiveUpdate" + } + } + } + } + }, + "operations": { + "streamUserNotifications": { + "action": "send", + "channel": {"$ref": "#/channels/user-notifications"}, + "messages": [ + {"$ref": "#/channels/user-notifications/messages/Notification"}, + {"$ref": "#/channels/user-notifications/messages/SystemAlert"} + ], + "x-the-codegen-project": { + "functionTypeMapping": ["event_source_express"] + } + }, + "streamLiveUpdates": { + "action": "send", + "channel": {"$ref": "#/channels/live-updates"}, + "messages": [{"$ref": "#/channels/live-updates/messages/LiveUpdate"}], + "x-the-codegen-project": { + "functionTypeMapping": ["event_source_express"] + } + } + }, + "components": { + "schemas": { + "Notification": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "userId": {"type": "string"}, + "type": {"type": "string", "enum": ["info", "warning", "error", "success"]}, + "title": {"type": "string"}, + "message": {"type": "string"}, + "timestamp": {"type": "string", "format": "date-time"}, + "actionUrl": {"type": "string", "format": "uri"}, + "read": {"type": "boolean", "default": false} + }, + "required": ["id", "userId", "type", "title", "message", "timestamp"] + }, + "SystemAlert": { + "type": "object", + "properties": { + "alertId": {"type": "string"}, + "severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]}, + "service": {"type": "string"}, + "message": {"type": "string"}, + "timestamp": {"type": "string", "format": "date-time"}, + "resolved": {"type": "boolean", "default": false} + }, + "required": ["alertId", "severity", "service", "message", "timestamp"] + }, + "LiveUpdate": { + "type": "object", + "properties": { + "topic": {"type": "string"}, + "data": {"type": "object"}, + "timestamp": {"type": "string", "format": "date-time"}, + "sequence": {"type": "integer"} + }, + "required": ["topic", "data", "timestamp"] + } + } + } +} +``` + ## FAQ ### How does it relate to AsyncAPI Generator and templates? @@ -78,3 +899,12 @@ It is fairly similar in functionality except in some key areas. Templates are similar to presets except you can bind presets together to make it easier to render code down stream. The AsyncAPI Generator is like the core of the Codegen Project, however it does not enable different inputs than AsyncAPI documents. + +### Can I mix multiple protocols in one document? +Yes! You can define operations with different protocol bindings in the same AsyncAPI document. Use the `x-the-codegen-project` extension to specify which generators to use for each operation. + +### How do I handle versioning? +Use the `info.version` field in your AsyncAPI document and consider using separate documents for major version changes. You can also use channel addressing patterns to include version information. + +### Can I customize the generated code structure? +Yes, use the `x-the-codegen-project` extension properties to customize channel names, function mappings, and other generation aspects. If you want full control, use the [custom preset](../generators/custom.md) diff --git a/docs/protocols/http_client.md b/docs/protocols/http_client.md new file mode 100644 index 00000000..08002438 --- /dev/null +++ b/docs/protocols/http_client.md @@ -0,0 +1,41 @@ +--- +sidebar_position: 99 +--- + +# HTTP(S) + +Both client and server generator is available. + +It is currently available through the generators ([channels](../generators/channels.md)): + +All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP `method` binding for operation and `statusCode` for messages. + +## TypeScript + +| **Feature** | Is supported? | +|---|---| +| Download | ➗ | +| Upload | ➗ | +| Offset based Pagination | ➗ | +| Cursor based Pagination | ➗ | +| Page based Pagination | ➗ | +| Time based Pagination | ➗ | +| Keyset based Pagination | ➗ | +| Retry with backoff | ➗ | +| OAuth2 Authorization code | ✔️ | +| OAuth2 Implicit | ✔️ | +| OAuth2 password | ✔️ | +| OAuth2 Client Credentials | ✔️ | +| Username/password Authentication | ✔️ | +| Bearer Authentication | ✔️ | +| Basic Authentication | ✔️ | +| API Key Authentication | ✔️ | +| XML Based API | ➗ | +| JSON Based API | ✔️ | +| POST | ✔️ | +| GET | ✔️ | +| PATCH | ✔️ | +| DELETE | ✔️ | +| PUT | ✔️ | +| HEAD | ✔️ | +| OPTIONS | ✔️ | \ No newline at end of file From efe1107c33902f27e78d76537180aca14d43efb8 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 24 May 2025 19:08:45 +0200 Subject: [PATCH 19/19] wip --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5ff8e019..20ea43b2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ [![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-) -

Generate payload models, parameters, headers, messages, communication support functions, testing functions, and more, across programming languages such as TypeScript, and soon more...

+

Generate payload models, parameters, headers, messages, communication support functions, and much more...

[Read the Docs](https://the-codegen-project.org/docs/) | [View Demos](./examples/README.md) @@ -51,7 +51,7 @@ - 💫 Regenerate once the input changes - 👀 Integrate it into any project (such as [Next.JS](./examples/typescript-nextjs), [TypeScript Libraries](./examples/typescript-library), you name it.) - 💅 [Create custom generators to your use-case](https://the-codegen-project.org/docs/generators/custom) -- 🗄️ Protocol agnostic generator ([NATS](https://the-codegen-project.org/docs/protocols/nats), [Kafka](https://the-codegen-project.org/docs/protocols/kafka), [MQTT](https://the-codegen-project.org/docs/protocols/mqtt), [AMQP](https://the-codegen-project.org/docs/protocols/amqp), [event-source](https://the-codegen-project.org/docs/protocols/eventsource), read the [docs](https://the-codegen-project.org/docs#protocols) for the full list and information) +- 🗄️ Protocol agnostic generator ([NATS](https://the-codegen-project.org/docs/protocols/nats), [Kafka](https://the-codegen-project.org/docs/protocols/kafka), [MQTT](https://the-codegen-project.org/docs/protocols/mqtt), [AMQP](https://the-codegen-project.org/docs/protocols/amqp), [event-source](https://the-codegen-project.org/docs/protocols/eventsource), [HTTP Client](https://the-codegen-project.org/docs/protocols/http_client), read the [docs](https://the-codegen-project.org/docs#protocols) for the full list and information) - ⭐ And much more... # How it works @@ -227,14 +227,16 @@ codegen generate # 👀 Goals Besides the [milestones](https://github.com/the-codegen-project/cli/milestones), we have certain goals that we want to reach for various reasons; -- ⭐ Reach 50 stars - So we can publish the CLI on Brew and Chocolatey -- 📃 3 Published resources (blog post, video, etc) +- [ ] ⭐ Reach 50 stars - So we can publish the CLI on Brew and Chocolatey +- [X] 📃 3 Published resources (blog post, video, etc) # 📃 Resources People who have been so kind to write or talk about The Codegen Project; +- [The Codegen Project - 1 Months of Progress](https://the-codegen-project.org/blog/update-2) - [The Codegen Project - AsyncAPI Extensions](https://the-codegen-project.org/blog/asyncapi-customizing-outputs) - [The Codegen Project - 5 Months of Progress](https://the-codegen-project.org/blog/update-1) + # Contribution Guidelines We have made quite a [comprehensive contribution guide](https://the-codegen-project.org/docs/contributing) to give you a lending hand in how the project is structured and how to contribute to it.