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/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. 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 7fad0902..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) +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; @@ -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/docs/inputs/asyncapi.md b/docs/inputs/asyncapi.md index 1ce0290e..0ca2ec8b 100644 --- a/docs/inputs/asyncapi.md +++ b/docs/inputs/asyncapi.md @@ -15,47 +15,881 @@ 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 + +### 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 + +Generate Kafka producers and consumers with proper serialization. + +#### 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 @@ -65,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 diff --git a/src/codegen/generators/helpers/payloads.ts b/src/codegen/generators/helpers/payloads.ts index f531bf7c..50e86d3c 100644 --- a/src/codegen/generators/helpers/payloads.ts +++ b/src/codegen/generators/helpers/payloads.ts @@ -38,13 +38,24 @@ 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 ); + 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 @@ -86,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/asyncapi.ts b/src/codegen/generators/typescript/channels/asyncapi.ts index effb6f2f..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 = [ @@ -29,7 +30,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, @@ -161,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/index.ts b/src/codegen/generators/typescript/channels/index.ts index fd502b4f..119a9938 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -42,6 +42,9 @@ export async function generateTypeScriptChannels( context: TypeScriptChannelsContext ): Promise { const protocolCodeFunctions: Record = {}; + + // Render before renders + const coreCode: string[] = []; const externalProtocolFunctionInformation: Record< string, TypeScriptChannelRenderedFunctionType[] 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 new file mode 100644 index 00000000..8999aa6a --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -0,0 +1,435 @@ +import { HttpRenderType } from "../../../../../types"; +import { pascalCase } from "../../../utils"; +import { ChannelFunctionTypes, RenderHttpParameters } from "../../types"; + +export function renderHttpFetchClient({ + requestTopic, + requestMessageType, + requestMessageModule, + replyMessageType, + replyMessageModule, + channelParameters, + method, + statusCodes = [], + servers = [], + subName = pascalCase(requestTopic), + functionName = `${method.toLowerCase()}${subName}`, +}: RenderHttpParameters): HttpRenderType { + const addressToUse = channelParameters + ? `parameters.getChannelWithParameters('${requestTopic}')` + : `'${requestTopic}'`; + const messageType = requestMessageModule + ? `${requestMessageModule}.${requestMessageType}` + : requestMessageType; + const replyType = replyMessageModule + ? `${replyMessageModule}.${replyMessageType}` + : replyMessageType; + const code = `async ${functionName}(context: { + server?: ${[...servers.map((value) => `'${value}'`), 'string'].join(' | ')}; + ${messageType ? `payload: ${messageType};` : ''} + path?: 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?: ({ + 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 = { + ...{ + makeRequestCallback: async ({url, body, method, headers}) => { + return NodeFetch.default(url, { + body, + method, + headers + }) + }, + path: ${addressToUse}, + 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 + }; + let url = \`\${parsedContext.server}\${parsedContext.path}\`; + + let body: any; + ${messageType ? `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}\`; + } + + // 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 + }); + + // 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')); + } + } + + // 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, + replyType, + code, + functionName, + 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 new file mode 100644 index 00000000..1efe99c4 --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -0,0 +1,161 @@ +/* eslint-disable security/detect-object-injection */ +import { + TypeScriptChannelRenderedFunctionType, + ChannelFunctionTypes, + TypeScriptChannelsGeneratorContext +} from '../../types'; +import { + findNameFromOperation, + findOperationId, + findReplyId +} from '../../../../../utils'; +import {getMessageTypeAndModule} from '../../utils'; +import { + shouldRenderFunctionType, + getFunctionTypeMappingFromAsyncAPI +} from '../../asyncapi'; +import {ChannelInterface} from '@asyncapi/parser'; +import {HttpRenderType, SingleFunctionRenderType} from '../../../../../types'; +import {ConstrainedObjectModel} from '@asyncapi/modelina'; +import {renderHttpFetchClient} from './fetch'; + +export {renderHttpFetchClient}; + +export async function generatehttpChannels( + context: TypeScriptChannelsGeneratorContext, + channel: ChannelInterface, + protocolCodeFunctions: Record, + externalProtocolFunctionInformation: Record< + string, + TypeScriptChannelRenderedFunctionType[] + >, + dependencies: string[] +) { + const {generator, parameter, topic} = context; + const ignoreOperation = !generator.asyncapiGenerateForOperations; + let renders: any[] = []; + const operations = channel.operations().all(); + if (operations.length > 0 && !ignoreOperation) { + renders = generateForOperations(context, channel, topic, parameter); + } + addRendersToExternal( + renders, + protocolCodeFunctions, + externalProtocolFunctionInformation, + dependencies, + parameter + ); +} + +function addRendersToExternal( + renders: SingleFunctionRenderType[], + protocolCodeFunctions: Record, + externalProtocolFunctionInformation: Record< + string, + TypeScriptChannelRenderedFunctionType[] + >, + dependencies: string[], + parameter?: ConstrainedObjectModel +) { + protocolCodeFunctions['http_client'].push(...renders.map((value) => value.code)); + + externalProtocolFunctionInformation['http_client'].push( + ...renders.map((value) => ({ + functionType: value.functionType, + functionName: value.functionName, + messageType: value.messageType, + replyType: value.replyType, + parameterType: parameter?.type + })) + ); + const renderedDependencies = renders + .map((value) => value.dependencies) + .flat(Infinity); + dependencies.push(...(new Set(renderedDependencies) as any)); +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +function generateForOperations( + context: TypeScriptChannelsGeneratorContext, + channel: ChannelInterface, + topic: string, + parameters: ConstrainedObjectModel | undefined +): HttpRenderType[] { + const renders: HttpRenderType[] = []; + const {generator, payloads} = context; + const functionTypeMapping = generator.functionTypeMapping[channel.id()]; + + for (const operation of channel.operations().all()) { + const updatedFunctionTypeMapping = + getFunctionTypeMappingFromAsyncAPI(operation) ?? functionTypeMapping; + const action = operation.action(); + if ( + shouldRenderFunctionType( + updatedFunctionTypeMapping, + ChannelFunctionTypes.HTTP_CLIENT, + action, + generator.asyncapiReverseOperations + ) + ) { + const httpMethod = + operation.bindings().get('http')?.json()['method'] ?? 'GET'; + const payloadId = findOperationId(operation, channel); + const payload = payloads.operationModels[payloadId]; + if (payload === undefined && httpMethod === 'POST') { + throw new Error( + `Could not find payload for ${payloadId} for channel typescript generator ${JSON.stringify(payloads.operationModels, null, 4)}` + ); + } + const {messageModule, messageType} = getMessageTypeAndModule(payload); + const reply = operation.reply(); + if (reply) { + const replyId = findReplyId(operation, reply, channel); + const replyMessageModel = payloads.operationModels[replyId]; + if (!replyMessageModel) { + throw new Error( + `Could not find payload for reply ${replyId} for channel typescript generator for HTTP` + ); + } + const statusCodes = operation + .reply() + ?.messages() + .all() + .map((value) => { + const statusCode = Number( + value.bindings().get('http')?.json()['statusCode'] + ); + return { + code: statusCode, + description: value.description() ?? 'Unknown', + messageModule, + messageType + }; + }); + const { + messageModule: replyMessageModule, + messageType: replyMessageType + } = getMessageTypeAndModule(replyMessageModel); + 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), + 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 + }) + ); + } + } + } + return renders; +} 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 62d421d6..ca3a9432 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -17,8 +17,9 @@ export enum ChannelFunctionTypes { KAFKA_PUBLISH = 'kafka_publish', KAFKA_SUBSCRIBE = 'kafka_subscribe', AMQP_QUEUE_PUBLISH = 'amqp_queue_publish', - AMQP_EXCHANGE_PUBLISH = 'amqp_exchange_publish', AMQP_QUEUE_SUBSCRIBE = 'amqp_queue_subscribe', + AMQP_EXCHANGE_PUBLISH = 'amqp_exchange_publish', + HTTP_CLIENT = 'http_client', EVENT_SOURCE_FETCH = 'event_source_fetch', EVENT_SOURCE_EXPRESS = 'event_source_express' } @@ -36,7 +37,7 @@ export const zodTypescriptChannelsGenerator = z.object({ .default('src/__gen__/channels') .describe('The path for which the generated channels will be saved'), protocols: z - .array(z.enum(['nats', 'kafka', 'mqtt', 'amqp', 'event_source'])) + .array(z.enum(['nats', 'kafka', 'mqtt', 'amqp', 'event_source', 'http_client'])) .default([]) .describe('Select which protocol to generate the channel code for'), parameterGeneratorId: z @@ -154,9 +155,24 @@ export interface RenderRequestReplyParameters { payloadGenerator: TypeScriptPayloadRenderType; } +export interface RenderHttpParameters { + requestTopic: string; + requestMessageType?: string; + servers?: string[]; + requestMessageModule: string | undefined; + replyMessageType: string; + replyMessageModule: string | undefined; + channelParameters: ConstrainedObjectModel | undefined; + statusCodes?: {code: number, description:string, messageModule?: string, messageType?: string}[] + subName?: string; + functionName?: string; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +} + export type SupportedProtocols = | 'nats' | 'kafka' | 'mqtt' | 'amqp' - | 'event_source'; + | 'event_source' + | 'http_client'; 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..6e84addc 100644 --- a/src/codegen/generators/typescript/payloads.ts +++ b/src/codegen/generators/typescript/payloads.ts @@ -209,6 +209,69 @@ function renderUnionUnmarshal( }`; } +/** + * Extract status code value from union member + */ +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 (!statusCode) { + return null; + } + + if (typeof statusCode === 'object' && statusCode.code !== undefined) { + return statusCode.code; + } + + if (typeof statusCode === 'number') { + return statusCode; + } + + 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 ''; + } + + 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 +404,8 @@ export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOp return validate; } ${renderUnionUnmarshal(model, renderer)} -${renderUnionMarshal(model)}`; +${renderUnionMarshal(model)} +${renderUnionUnmarshalByStatusCode(model)}`; } return content; } 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 index c0524cf8..7107c4f8 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -1071,515 +1071,395 @@ 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, URL } 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, - 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('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 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 - */ -subscribeToPing: ({ - 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('ping', options); - - (async () => { - for await (const msg of subscription) { - - let receivedData: any = codec.decode(msg.data); +http_client: { + async getPingRequest(context: { + server?: string; + + path?: 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?: ({ + 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', + apiKeyIn: 'header', + apiKeyName: 'X-API-Key', + }, + ...context, + } -onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); - } - })(); - resolve(subscription); - } catch (e: any) { - reject(e); + // 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')); } - }); -}, -/** - * 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 transmitting the message - * @param skipMessageValidation turn off runtime validation of incoming messages - */ -jetStreamPullSubscribeToPing: ({ - 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('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); + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Implicit flow requires clientId')); } - }); -}, -/** - * 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 transmitting the message - * @param skipMessageValidation turn off runtime validation of incoming messages - */ -jetStreamPushSubscriptionFromPing: ({ - 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('ping', options); - - (async () => { - for await (const msg of subscription) { - - let receivedData: any = codec.decode(msg.data); + 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')); + } + } -onDataCallback(undefined, MessageTypeModule.unmarshal(receivedData), msg); - } - })(); - resolve(subscription); - } catch (e: any) { - reject(e); + // 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')); } - }); -}, -/** - * 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, - 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('ping', dataToSend, options); - resolve(); - } catch (e: any) { - reject(e); + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId')); } - }); -} -}, -amqp: { - /** - * AMQP publish operation for exchange \`/ping\` - * - * @param message to publish - * @param amqp the AMQP connection to send over - * @param options for the AMQP publish exchange operation - */ -publishToPingExchange: ({ - 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') + } + + // 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')); } - 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); + if (!parsedContext.oauth2.clientId) { + return Promise.reject(new Error('OAuth2 Password flow requires clientId')); } - }); -}, -/** - * AMQP publish operation for queue \`/ping\` - * - * @param message to publish - * @param amqp the AMQP connection to send over - * @param options for the AMQP publish queue operation - */ -publishToPingQueue: ({ - 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 = '/ping'; -channel.sendToQueue(queue, Buffer.from(dataToSend), options); - resolve(); - } catch (e: any) { - reject(e); + if (!parsedContext.oauth2.username) { + return Promise.reject(new Error('OAuth2 Password flow requires username')); } - }); -}, -/** - * AMQP subscribe operation for queue \`/ping\` - * - * @param {subscribeToPingQueueCallback} 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 - */ -subscribeToPingQueue: ({ - 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 = '/ping'; -await channel.assertQueue(queue, { durable: true }); + if (!parsedContext.oauth2.password) { + return Promise.reject(new Error('OAuth2 Password flow requires password')); + } + } -channel.consume(queue, (msg) => { - if (msg !== null) { - const receivedData = msg.content.toString() + 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); + authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!); + authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token'); - const message = MessageTypeModule.unmarshal(receivedData); - onDataCallback(undefined, message, msg); - } -}, options); - resolve(channel); - } catch (e: any) { - reject(e); + if (parsedContext.oauth2.state) { + authUrl.searchParams.append('state', parsedContext.oauth2.state); } - }); -} -}, -mqtt: { - /** - * MQTT publish operation for \`/ping\` - * - * @param message to publish - * @param mqtt the MQTT client to publish from - */ -publishToPing: ({ - message, - mqtt -}: { - 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); + + if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) { + authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' ')); } - }); -} -}, -kafka: { - /** - * Kafka publish operation for \`ping\` - * - * @param message to publish - * @param kafka the KafkaJS client to publish from - */ -produceToPing: ({ - message, - kafka -}: { - message: MessageTypeModule.MessageType, - kafka: Kafka.Kafka -}): Promise => { - return new Promise(async (resolve, reject) => { + + // 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: 'GET', + headers, + body + }); + + // Handle OAuth2 Client Credentials flow + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) { try { - let dataToSend: any = MessageTypeModule.marshal(message); - const producer = kafka.producer(); - await producer.connect(); - await producer.send({ - topic: 'ping', - messages: [ - { value: dataToSend }, - ], + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: parsedContext.oauth2.clientId }); - resolve(producer); - } catch (e: any) { - reject(e); + + 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); } - }); -}, -/** - * 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 - * @param skipMessageValidation turn off runtime validation of incoming messages - */ -consumeFromPing: ({ - 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) => { + // Handle OAuth2 password flow + if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) { try { - if(!options.groupId) { - return reject('No group ID provided'); + 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); } - 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 receivedData = message.value?.toString()!; - - -const callbackData = MessageTypeModule.unmarshal(receivedData); -onDataCallback(undefined, callbackData, kafkaMessage); - } + 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() }); - resolve(consumer); - } catch (e: any) { - reject(e); + + 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); } - }); -} -}, -event_source: { - /** - * Event source fetch for \`/ping\` - * - * @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 - */ -listenForPing: 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 = '/ping'; - const url = \`\${options.baseUrl}/\${eventsUrl}\` - const headers: Record = { - ...options.headers ?? {}, - Accept: 'text/event-stream' } - if(options.authorization) { - headers['authorization'] = \`Bearer \${options?.authorization}\`; + + // 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')); + } } - 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')); - } - }, - }) -} -, -registerPing: ({ - 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 = '/ping'; - router.get(event, async (req, res, next) => { + // 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 - 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\`) + } + + // 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('Internal Server Error')); + } else { + return Promise.reject(new Error(\`HTTP Error: \${response.status} \${response.statusText}\`)); } - await callback(req, res, next, sendEventCallback) - }) + } } - }};" `; diff --git a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap index 9cbd985c..72afd67d 100644 --- a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap @@ -111,6 +111,7 @@ return payload.marshal(); return JSON.stringify(payload); } + export { UnionPayload };" `; diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index dd185256..42638e8f 100644 --- a/test/codegen/generators/typescript/channels.spec.ts +++ b/test/codegen/generators/typescript/channels.spec.ts @@ -65,10 +65,18 @@ describe('channels', () => { } }, operationModels: { - pingRequestId: { + 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'] + asyncapiGenerateForOperations: true, + protocols: ['http_client'] }, inputType: 'asyncapi', asyncapiDocument: parsedAsyncAPIDocument, diff --git a/test/configs/asyncapi-request.yaml b/test/configs/asyncapi-request.yaml index a1449894..911546e2 100644 --- a/test/configs/asyncapi-request.yaml +++ b/test/configs/asyncapi-request.yaml @@ -13,17 +13,33 @@ 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' messages: - $ref: '#/channels/ping/messages/ping' reply: - id: pingRequestId channel: $ref: '#/channels/ping' messages: - $ref: '#/channels/ping/messages/pong' + bindings: + http: + method: GET components: messages: ping: @@ -39,4 +55,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/configs/openapi.yaml b/test/configs/openapi.yaml new file mode 100644 index 00000000..a8f9809a --- /dev/null +++ b/test/configs/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 diff --git a/test/runtime/asyncapi.json b/test/runtime/asyncapi-regular.json similarity index 56% rename from test/runtime/asyncapi.json rename to test/runtime/asyncapi-regular.json index d0e8acd7..d482e0c7 100644 --- a/test/runtime/asyncapi.json +++ b/test/runtime/asyncapi-regular.json @@ -29,17 +29,6 @@ "$ref": "#/components/messages/UserSignedUp" } } - }, - "ping": { - "address": "/ping", - "messages": { - "ping": { - "$ref": "#/components/messages/ping" - }, - "pong": { - "$ref": "#/components/messages/pong" - } - } } }, "operations": { @@ -64,42 +53,6 @@ "$ref": "#/channels/userSignedup/messages/UserSignedUp" } ] - }, - "pingRequest": { - "action": "send", - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/ping"} - ], - "reply": { - "id": "pingRequestId", - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/pong"} - ] - } - }, - "pongReply": { - "action": "receive", - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/pong"} - ], - "reply": { - "id": "pingReplyId", - "channel": { - "$ref": "#/channels/ping" - }, - "messages": [ - {"$ref": "#/channels/ping/messages/ping"} - ] - } } }, "components": { @@ -111,28 +64,6 @@ "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" - } - } - } } }, "schemas": { 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/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 diff --git a/test/runtime/typescript/codegen.mjs b/test/runtime/typescript/codegen-regular.mjs similarity index 86% rename from test/runtime/typescript/codegen.mjs rename to test/runtime/typescript/codegen-regular.mjs index de136b01..9310a494 100644 --- a/test/runtime/typescript/codegen.mjs +++ b/test/runtime/typescript/codegen-regular.mjs @@ -1,7 +1,7 @@ -/** @type {import("../../../").TheCodegenConfiguration} **/ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ export default { inputType: 'asyncapi', - inputPath: '../asyncapi.json', + inputPath: '../asyncapi-regular.json', language: 'typescript', generators: [ { diff --git a/test/runtime/typescript/codegen-request-reply.mjs b/test/runtime/typescript/codegen-request-reply.mjs new file mode 100644 index 00000000..e99779f7 --- /dev/null +++ b/test/runtime/typescript/codegen-request-reply.mjs @@ -0,0 +1,31 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'asyncapi', + inputPath: '../asyncapi-request-reply.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/package-lock.json b/test/runtime/typescript/package-lock.json index 0310a2a8..14ce08be 100644 --- a/test/runtime/typescript/package-lock.json +++ b/test/runtime/typescript/package-lock.json @@ -10,17 +10,21 @@ "@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" } }, @@ -1169,6 +1173,31 @@ "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.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": { + "node": ">= 6" + } + }, "node_modules/@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -2173,6 +2202,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", @@ -2676,6 +2720,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/package.json b/test/runtime/typescript/package.json index a5cbb28f..9e560acd 100644 --- a/test/runtime/typescript/package.json +++ b/test/runtime/typescript/package.json @@ -1,14 +1,21 @@ { "scripts": { - "test": "npm run test:kafka && npm run test:nats && npm run test:mqtt && npm run test:amqp && npm run test:eventsource", - "test:kafka": "jest -- ./test/channels/kafka.spec.ts", + "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/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", - "generate": "node ../../../bin/run.mjs generate" + "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", + "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", @@ -22,11 +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/regular/amqp.spec.ts similarity index 95% rename from test/runtime/typescript/test/channels/amqp.spec.ts rename to test/runtime/typescript/test/channels/regular/amqp.spec.ts index 462e954f..b99e924a 100644 --- a/test/runtime/typescript/test/channels/amqp.spec.ts +++ b/test/runtime/typescript/test/channels/regular/amqp.spec.ts @@ -1,10 +1,10 @@ /* eslint-disable no-console */ -import { Protocols } from '../../src/channels/index'; +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'; +import { UserSignedUp } from '../../../src/payloads/UserSignedUp'; +import { UserSignedupParameters } from '../../../src/parameters/UserSignedupParameters'; describe('amqp', () => { const testMessage = new UserSignedUp({displayName: 'test', email: 'test@test.dk'}); diff --git a/test/runtime/typescript/test/channels/eventsource.spec.ts b/test/runtime/typescript/test/channels/regular/eventsource.spec.ts similarity index 95% rename from test/runtime/typescript/test/channels/eventsource.spec.ts rename to test/runtime/typescript/test/channels/regular/eventsource.spec.ts index 2f274d97..423e399a 100644 --- a/test/runtime/typescript/test/channels/eventsource.spec.ts +++ b/test/runtime/typescript/test/channels/regular/eventsource.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ -import { Protocols } from '../../src/channels/index'; -import { UserSignedupParameters } from '../../src/parameters/UserSignedupParameters'; -import { UserSignedUp } from '../../src/payloads/UserSignedUp'; +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; diff --git a/test/runtime/typescript/test/channels/kafka.spec.ts b/test/runtime/typescript/test/channels/regular/kafka.spec.ts similarity index 96% rename from test/runtime/typescript/test/channels/kafka.spec.ts rename to test/runtime/typescript/test/channels/regular/kafka.spec.ts index c375daf7..b8b1c323 100644 --- a/test/runtime/typescript/test/channels/kafka.spec.ts +++ b/test/runtime/typescript/test/channels/regular/kafka.spec.ts @@ -1,10 +1,10 @@ -import { Protocols } from '../../src/channels'; +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'; +import { UserSignedupParameters } from '../../../src/parameters/UserSignedupParameters'; +import { UserSignedUp } from '../../../src/payloads/UserSignedUp'; const kafkaClient = new Kafka({ clientId: 'test', brokers: ['localhost:9093'], diff --git a/test/runtime/typescript/test/channels/mqtt.spec.ts b/test/runtime/typescript/test/channels/regular/mqtt.spec.ts similarity index 90% rename from test/runtime/typescript/test/channels/mqtt.spec.ts rename to test/runtime/typescript/test/channels/regular/mqtt.spec.ts index ba2b351d..6ef38127 100644 --- a/test/runtime/typescript/test/channels/mqtt.spec.ts +++ b/test/runtime/typescript/test/channels/regular/mqtt.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ -import { Protocols } from '../../src/channels/index'; -import { UserSignedupParameters } from '../../src/parameters/UserSignedupParameters'; -import { UserSignedUp } from '../../src/payloads/UserSignedUp'; +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'; diff --git a/test/runtime/typescript/test/channels/nats.spec.ts b/test/runtime/typescript/test/channels/regular/nats.spec.ts similarity index 91% rename from test/runtime/typescript/test/channels/nats.spec.ts rename to test/runtime/typescript/test/channels/regular/nats.spec.ts index 4b241191..88f2d205 100644 --- a/test/runtime/typescript/test/channels/nats.spec.ts +++ b/test/runtime/typescript/test/channels/regular/nats.spec.ts @@ -1,13 +1,11 @@ /* 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"; +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, replyToPongReply, requestToPingRequest } = nats; + jetStreamPublishToNoParameter, jetStreamPullSubscribeToNoParameter, jetStreamPushSubscriptionFromNoParameter, publishToNoParameter, subscribeToNoParameter } = nats; describe('nats', () => { const testMessage = new UserSignedUp({ displayName: 'test', email: 'test@test.dk' }); @@ -476,39 +474,6 @@ describe('nats', () => { 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(); - }); - }); }); }); }); 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(); + }); + }); + }); + }); +});