diff --git a/.gitignore b/.gitignore index 2c3b7cb..f84e69a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ lib/ node_modules/ package-lock.json -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +*.log \ No newline at end of file diff --git a/README.md b/README.md index ef75877..ce5af46 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,156 @@ # `@samchon/openapi` -![Nestia Editor](https://github.com/samchon/openapi/assets/13158709/350128f7-c159-4ba4-8f8c-743908ada8eb) +```mermaid +flowchart + subgraph "OpenAPI Specification" + v20("Swagger v2.0") --upgrades--> emended[["OpenAPI v3.1 (emended)"]] + v30("OpenAPI v3.0") --upgrades--> emended + v31("OpenAPI v3.1") --emends--> emended + end + subgraph "Ecosystem" + emended --normalizes--> migration[["Migration Schema"]] + migration --"Artificial Intelligence"--> lfc{{"LLM Function Calling Application"}} + end +``` [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/samchon/openapi/blob/master/LICENSE) [![npm version](https://img.shields.io/npm/v/@samchon/openapi.svg)](https://www.npmjs.com/package/@samchon/openapi) [![Downloads](https://img.shields.io/npm/dm/@samchon/openapi.svg)](https://www.npmjs.com/package/@samchon/openapi) [![Build Status](https://github.com/samchon/openapi/workflows/build/badge.svg)](https://github.com/samchon/openapi/actions?query=workflow%3Abuild) -OpenAPI definitions and converters. +OpenAPI definitions, converters and utillity functions. -`@samchon/openapi` is a collection of OpenAPI definitions of below versions. Those type definitions do not contain every properties of OpenAPI specification, but just have only significant features essentially required for `typia`, `nestia` (especially for [`@nestia/editor`](https://nestia.io/docs/editor/)). +`@samchon/openapi` is a collection of OpenAPI types for every versions, and converters for them. In the OpenAPI types, there is an "emended" OpenAPI v3.1 specification, which has removed ambiguous and duplicated expressions for the clarity. Every conversions are based on the emended OpenAPI v3.1 specification. 1. [Swagger v2.0](https://github.com/samchon/openapi/blob/master/src/SwaggerV2.ts) 2. [OpenAPI v3.0](https://github.com/samchon/openapi/blob/master/src/OpenApiV3.ts) 3. [OpenAPI v3.1](https://github.com/samchon/openapi/blob/master/src/OpenApiV3_1.ts) + 4. [**OpenAPI v3.1 emended**](https://github.com/samchon/openapi/blob/master/src/OpenApi.ts) -Also, `@samchon/openapi` provides emended OpenAPI v3.1 definition and its converter/inverter from above versions for convenient development. The keyword "emended" means that [`OpenApi`](https://github.com/samchon/openapi/blob/master/src/OpenApi.ts) is not a direct OpenAPI v3.1 specification ([`OpenApiV3_1`](https://github.com/samchon/openapi/blob/master/src/OpenApiV3_1.ts)), but a little bit shrinked to remove ambiguous and duplicated expressions of OpenAPI v3.1 for the convenience of `typia` and `nestia`. +`@samchon/openapi` also provides LLM (Large Language Model) function calling application composer from the OpenAPI document with many strategies. With the [`HttpLlm`](https://github.com/samchon/openapi/blob/master/src/HttpLlm.ts) module, you can perform the LLM funtion calling extremely easily just by delivering the OpenAPI (Swagger) document. -For example, when representing nullable type, OpenAPI v3.1 supports three ways. In that case, OpenApi remains only the third way, so that makes `typia` and `nestia` (especially for [`@nestia/editor`](https://nestia.io/docs/editor/)) to be simple and easy to implement. + - [`HttpLlm.application()`](https://github.com/samchon/openapi/blob/master/src/HttpLlm.ts) + - [`IHttpLlmApplication`](https://github.com/samchon/openapi/blob/master/src/structures/ILlmApplication.ts) + - [`IHttpLlmFunction`](https://github.com/samchon/openapi/blob/master/src/structures/ILlmFunction.ts) + - [`ILlmSchema`](https://github.com/samchon/openapi/blob/master/src/structures/ILlmSchema.ts) + - [`LlmTypeChecker`](https://github.com/samchon/openapi/blob/master/src/utils/LlmTypeChecker.ts) -- `{ type: ["string", "null"] }` -- `{ type: "string", nullable: true }` -- `{ oneOf: [{ type: "string" }, { type: "null" }] }` +> [!TIP] +> +> LLM selects proper function and fill arguments. +> +> In nowadays, most LLM (Large Language Model) like OpenAI are supporting "function calling" feature. The "LLM function calling" means that LLM automatically selects a proper function and fills parameter values from conversation with the user (may by chatting text). +> +> https://platform.openai.com/docs/guides/function-calling -```mermaid -flowchart - v20(Swagger v2.0) --upgrades--> emended[["OpenAPI v3.1 (emended)"]] - v30(OpenAPI v3.0) --upgrades--> emended - v31(OpenAPI v3.1) --emends--> emended - emended --downgrades--> v20d(Swagger v2.0) - emended --downgrades--> v30d(Swagger v3.0) -``` - -Here is the entire list of differences between OpenAPI v3.1 and emended OpenApi. -- Operation - - Merge `OpenApiV3_1.IPathItem.parameters` to `OpenApi.IOperation.parameters` - - Resolve references of `OpenApiV3_1.IOperation` members - - Escape references of `OpenApiV3_1.IComponents.examples` -- JSON Schema - - Decompose mixed type: `OpenApiV3_1.IJsonSchema.IMixed` - - Resolve nullable property: `OpenApiV3_1.IJsonSchema.__ISignificant.nullable` - - Array type utilizes only single `OpenAPI.IJsonSchema.IArray.items` - - Tuple type utilizes only `OpenApi.IJsonSchema.ITuple.prefixItems` - - Merge `OpenApiV3_1.IJsonSchema.IAnyOf` to `OpenApi.IJsonSchema.IOneOf` - - Merge `OpenApiV3_1.IJsonSchema.IRecursiveReference` to `OpenApi.IJsonSchema.IReference` - - Merge `OpenApiV3_1.IJsonSchema.IAllOf` to `OpenApi.IJsonSchema.IObject` -Additionally, `@samchon/openapi` provides [`IMigrateDocument`](https://github.com/samchon/openapi/blob/master/src/IMigrateDocument.ts) for OpenAPI generators. -If you're developing TypeScript, [`@nestia/editor`](https://nestia.io/docs/editor) would be the best project utilizing the [`IMigrateDocument`](https://github.com/samchon/openapi/blob/master/src/IMigrateDocument.ts) for the OpenAPI SDK generation. Otherwise, you wanna utilize OpenAPI document for OpenAI function calling, [`@wrtnio/openai-function-schema`](https://github.com/wrtnio/openai-function-schema/) has been prepared for you. - -```mermaid -flowchart - subgraph "OpenAPI Specification" - v20(Swagger v2.0) --upgrades--> emended[["OpenAPI v3.1 (emended)"]] - v30(OpenAPI v3.0) --upgrades--> emended - v31(OpenAPI v3.1) --emends--> emended - end - subgraph "OpenAPI Generators" - emended --normalizes--> migration[["Migration Schema"]] - migration --A.I.--> lfc{{"LLM Function Call"}} - migration --Uiltiy--> editor{{"@nestia/editor"}} - end -``` - - - - -## How to use +## Setup ```bash npm install @samchon/openapi ``` +Just install by `npm i @samchon/openapi` command. + +Here is an example code utilizing the `@samchon/openapi` for LLM function calling purpose. + ```typescript import { + HttpLlm, + IHttpLlmApplication, + IHttpLlmFunction, OpenApi, - SwaggerV2, OpenApiV3, OpenApiV3_1, - IMigrateDocument, + SwaggerV2, } from "@samchon/openapi"; +import fs from "fs"; +import typia from "typia"; -// original Swagger/OpenAPI document -const input: - | SwaggerV2.IDocument - | OpenApiV3.IDocument - | OpenApiV3_1.IDocument - | OpenApi.IDocument = { ... }; +const main = async (): Promise => { + // read swagger document and validate it + const swagger: + | SwaggerV2.IDocument + | OpenApiV3.IDocument + | OpenApiV3_1.IDocument = JSON.parse( + await fs.promises.readFile("swagger.json", "utf8"), + ); + typia.assert(swagger); // recommended + + // convert to emended OpenAPI document, + // and compose LLM function calling application + const document: OpenApi.IDocument = OpenApi.convert(swagger); + const application: IHttpLlmApplication = HttpLlm.application(document); + + // Let's imagine that LLM has selected a function to call + const func: IHttpLlmFunction | undefined = application.functions.find( + // (f) => f.name === "llm_selected_fuction_name" + (f) => f.path === "/bbs/articles" && f.method === "post", + ); + if (func === undefined) throw new Error("No matched function exists."); + + // actual execution is by yourself + const article = await HttpLlm.execute({ + connection: { + host: "http://localhost:3000", + }, + application, + function: func, + arguments: [ + { + title: "Hello, world!", + body: "Let's imagine that this argument is composed by LLM.", + thumbnail: null, + }, + ], + }); + console.log("article", article); +}; +main().catch(console.error); +``` -// you can convert it to emended OpenAPI v3.1 -const output: OpenApi.IDocument = OpenApi.convert(input); -// it is possible to downgrade to Swagger v2 or OpenAPI v3 -const v2: SwaggerV2 = OpenApi.downgrade(output, "2.0"); -const v3: OpenApiV3 = OpenApi.downgrade(output, "3.0"); -// you can utilize it like below -OpenApi.downgrade(OpenApi.convert(v2), "3.0"); -OpenApi.downgrade(OpenApi.convert(v3), "2.0"); -// also helps openapi generator libraries -const migrate: IMigrateDocument = OpenApi.migrate(output); +## OpenAPI Definitions +```mermaid +flowchart + v20(Swagger v2.0) --upgrades--> emended[["OpenAPI v3.1 (emended)"]] + v30(OpenAPI v3.0) --upgrades--> emended + v31(OpenAPI v3.1) --emends--> emended + emended --downgrades--> v20d(Swagger v2.0) + emended --downgrades--> v30d(Swagger v3.0) ``` -Just install `@samchon/openapi` library and import `OpenApi` module from there. +`@samchon/openapi` support every versions of OpenAPI specifications with detailed TypeScript types. + + - [Swagger v2.0](https://github.com/samchon/openapi/blob/master/src/SwaggerV2.ts) + - [OpenAPI v3.0](https://github.com/samchon/openapi/blob/master/src/OpenApiV3.ts) + - [OpenAPI v3.1](https://github.com/samchon/openapi/blob/master/src/OpenApiV3_1.ts) + - [**OpenAPI v3.1 emended**](https://github.com/samchon/openapi/blob/master/src/OpenApi.ts) -Every features you need from `@samchon/openapi` are in the `OpenApi` module. If you want to connvert emended OpenAPI v3.1 type, just call `OpenApi.convert()` function with your document. Otherwise you want to downgrade from the OpenAPI v3.1 emended specification, call `OpenApi.migrate()` instead. Of course, if you combine both `OpenApi.convert()` and `OpenApi.migrate()` functions, you can transform every OpenAPI versions. +Also, `@samchon/openapi` provides "emended OpenAPI v3.1 definition" which has removed ambiguous and duplicated expressions for clarity. It has emended original OpenAPI v3.1 specification like above. You can compose the "emended OpenAPI v3.1 document" by calling the `OpenApi.convert()` function. -By the way, if you want to validate whether your OpenAPI document is following the standard specification or not, you can do it on the playground website. Click one of below links, and paste your OpenAPI URL address. Of course, if you wanna validate in your local machine, just install [`typia`](https://github.com/samchon/typia) and write same code of playground. + - Operation + - Merge `OpenApiV3_1.IPathItem.parameters` to `OpenApi.IOperation.parameters` + - Resolve references of `OpenApiV3_1.IOperation` members + - Escape references of `OpenApiV3_1.IComponents.examples` + - JSON Schema + - Decompose mixed type: `OpenApiV3_1.IJsonSchema.IMixed` + - Resolve nullable property: `OpenApiV3_1.IJsonSchema.__ISignificant.nullable` + - Array type utilizes only single `OpenAPI.IJsonSchema.IArray.items` + - Tuple type utilizes only `OpenApi.IJsonSchema.ITuple.prefixItems` + - Merge `OpenApiV3_1.IJsonSchema.IAnyOf` to `OpenApi.IJsonSchema.IOneOf` + - Merge `OpenApiV3_1.IJsonSchema.IRecursiveReference` to `OpenApi.IJsonSchema.IReference` + - Merge `OpenApiV3_1.IJsonSchema.IAllOf` to `OpenApi.IJsonSchema.IObject` - - [💻 Type assertion](https://typia.io/playground/?script=JYWwDg9gTgLgBAbzgeTAUwHYEEzADQrra4BqAzAapjsOQPoCMBAygO4CGA5p2lCQExwAvnABmUCCDgAiAAIBndiADGACwgYA9BCLtc0gNwAoUJFhwYAT1zsxEqdKs3DRo8o3z4IdsAxwAvHDs8pYYynAAFACUAFxwAAr2wPJoADwAbhDAACYAfAH5CEZwcJqacADiAKIAKnAAmsgAqgBKKPFVAHJY8QCScAAiyADCTQCyXTXFcO4YnnBQaPKQc2hxLUsrKQFBHMDwomgwahHTJdKqMDBg8jFlUOysAHSc+6oArgBG7ylQszCYGBPdwgTSKFTqLQ6TB6YCabyeXiaNAADyUYAANktNOkyE8AAzaXTAJ4AK3kGmk0yixhKs3m2QgyneIEBcXYGEsO0ePngi2WHjQZIpGGixmmZTgNXqHTgWGYzCqLRqvWQnWmTmA7CewV+MAq73YUGyqTOcAAPoRqKQyIwnr0BkyWYCzZaqMRaHiHU7WRgYK64GwuDw+Px7Y7mb7-SVchFGZHATTXCVJcM1SQlXUasg4FUJp0BlUBtN6fA0L7smhsnF3TRwz7ATta7hgRp0rwYHGG36k3SPBAsU9fKIIBFy5hK9kk0JjN5fNFgexjqoIvSB0LeBIoDSgA) - - [💻 Detailed validation](https://typia.io/playground/?script=JYWwDg9gTgLgBAbzgeTAUwHYEEzADQrra4BqAzAapjsOQPoCMBAygO4CGA5p2lCQExwAvnABmUCCDgAiAAIBndiADGACwgYA9BCLtc0gNwAoUJFhwYAT1zsxEqdKs3DRo8o3z4IdsAxwAvHDs8pYYynAAFACUAFxwAAr2wPJoADwAbhDAACYAfAH5CEZwcJqacADiAKIAKnAAmsgAqgBKKPFVAHJY8QCScAAiyADCTQCyXTXFcO4YnnBQaPKQc2hxLUsrKQFBHMDwomgwahHTJdKqMDBg8jFlUOysAHSc+6oArgBG7ylQszCYGBPdwgTSKFTqLQ6TB6YCabyeXiaNAADyUYAANktNOkyE8AAzaXTAJ4AK3kGmk0yixhKs3m2QgyneIEBcXYGEsO0ePngi2WHjQZIpGGixmmZTgNXqHTgJCwABlegMsDVeshOtN6Xylu8MfBAk5gOwnul2BicuwAakznAAD6EaikMiMJ7KpkswG2h1UYi0PHu5msjAwb1wNhcHh8fhugYe4Ohkq5CKMoOAmnTYCiSL8vVA+TvZTKJbyAL+QKic0pKKIW30iBYp6+UQQCK5-VPXgSKDyDMlEqLGDvKAYWnCVwlSXDDUkKotOo1ZBwKoTToDKoDLUeeBoYPZNDZOK+mix+OAnbH3DAjTpXgwFNnkN9mYeBtC5ut3eYffZDNCYzeL40TAlaJz1o2XbQDSQA) +Conversions to another version's OpenAPI document is also based on the "emended OpenAPI v3.1 specification" like above diagram. You can do it through `OpenApi.downgrade()` function. Therefore, if you want to convert Swagger v2.0 document to OpenAPI v3.0 document, you have to call two functions; `OpenApi.convert()` and then `OpenApi.downgrade()`. + +At last, if you utilize `typia` library with `@samchon/openapi` types, you can validate whether your OpenAPI document is following the standard specification or not. Just visit one of below playground links, and paste your OpenAPI document URL address. This validation strategy would be superior than any other OpenAPI validator libraries. + + - Playground Links + - [💻 Type assertion](https://typia.io/playground/?script=JYWwDg9gTgLgBAbzgeTAUwHYEEzADQrra4BqAzAapjsOQPoCMBAygO4CGA5p2lCQExwAvnABmUCCDgAiAAIBndiADGACwgYA9BCLtc0gNwAoUJFhwYAT1zsxEqdKs3DRo8o3z4IdsAxwAvHDs8pYYynAAFACUAFxwAAr2wPJoADwAbhDAACYAfAH5CEZwcJqacADiAKIAKnAAmsgAqgBKKPFVAHJY8QCScAAiyADCTQCyXTXFcO4YnnBQaPKQc2hxLUsrKQFBHMDwomgwahHTJdKqMDBg8jFlUOysAHSc+6oArgBG7ylQszCYGBPdwgTSKFTqLQ6TB6YCabyeXiaNAADyUYAANktNOkyE8AAzaXTAJ4AK3kGmk0yixhKs3m2QgyneIEBcXYGEsO0ePngi2WHjQZIpGGixmmZTgNXqHTgWGYzCqLRqvWQnWmTmA7CewV+MAq73YUGyqTOcAAPoRqKQyIwnr0BkyWYCzZaqMRaHiHU7WRgYK64GwuDw+Px7Y7mb7-SVchFGZHATTXCVJcM1SQlXUasg4FUJp0BlUBtN6fA0L7smhsnF3TRwz7ATta7hgRp0rwYHGG36k3SPBAsU9fKIIBFy5hK9kk0JjN5fNFgexjqoIvSB0LeBIoDSgA) + - [💻 Detailed validation](https://typia.io/playground/?script=JYWwDg9gTgLgBAbzgeTAUwHYEEzADQrra4BqAzAapjsOQPoCMBAygO4CGA5p2lCQExwAvnABmUCCDgAiAAIBndiADGACwgYA9BCLtc0gNwAoUJFhwYAT1zsxEqdKs3DRo8o3z4IdsAxwAvHDs8pYYynAAFACUAFxwAAr2wPJoADwAbhDAACYAfAH5CEZwcJqacADiAKIAKnAAmsgAqgBKKPFVAHJY8QCScAAiyADCTQCyXTXFcO4YnnBQaPKQc2hxLUsrKQFBHMDwomgwahHTJdKqMDBg8jFlUOysAHSc+6oArgBG7ylQszCYGBPdwgTSKFTqLQ6TB6YCabyeXiaNAADyUYAANktNOkyE8AAzaXTAJ4AK3kGmk0yixhKs3m2QgyneIEBcXYGEsO0ePngi2WHjQZIpGGixmmZTgNXqHTgJCwABlegMsDVeshOtN6Xylu8MfBAk5gOwnul2BicuwAakznAAD6EaikMiMJ7KpkswG2h1UYi0PHu5msjAwb1wNhcHh8fhugYe4Ohkq5CKMoOAmnTYCiSL8vVA+TvZTKJbyAL+QKic0pKKIW30iBYp6+UQQCK5-VPXgSKDyDMlEqLGDvKAYWnCVwlSXDDUkKotOo1ZBwKoTToDKoDLUeeBoYPZNDZOK+mix+OAnbH3DAjTpXgwFNnkN9mYeBtC5ut3eYffZDNCYzeL40TAlaJz1o2XbQDSQA) ```typescript import { OpenApi, OpenApiV3, OpenApiV3_1, SwaggerV2 } from "@samchon/openapi"; @@ -144,7 +184,270 @@ main().catch(console.error); -## Related Projects -- `typia`: https://github.com/samchon/typia -- `nestia`: https://github.com/samchon/nestia -- `@wrtnio/openai-function-schema`: https://github.com/wrtnio/openai-function-schema \ No newline at end of file +## LLM Function Calling +### Preface +```mermaid +flowchart + subgraph "OpenAPI Specification" + v20("Swagger v2.0") --upgrades--> emended[["OpenAPI v3.1 (emended)"]] + v30("OpenAPI v3.0") --upgrades--> emended + v31("OpenAPI v3.1") --emends--> emended + end + subgraph "Ecosystem" + emended --normalizes--> migration[["Migration Schema"]] + migration --"Artificial Intelligence"--> lfc{{"LLM Function Calling Application"}} + end +``` + +LLM function calling application from OpenAPI document. + +`@samchon/openapi` provides LLM (Large Language Model) funtion calling application from the "emended OpenAPI v3.1 document". Therefore, if you have any HTTP backend server and succeeded to build an OpenAPI document, you can easily make the A.I. chatbot application. + +In the A.I. chatbot, LLM will select proper function to remotely call from the conversations with user, and fill arguments of the function automatically. If you actually execute the function call through the `HttpLlm.execute()` funtion, it is the "LLM function call." + +Let's enjoy the fantastic LLM function calling feature very easily with `@samchon/openapi`. + + - [`HttpLlm.application()`](https://github.com/samchon/openapi/blob/master/src/HttpLlm.ts) + - [`IHttpLlmApplication`](https://github.com/samchon/openapi/blob/master/src/structures/ILlmApplication.ts) + - [`IHttpLlmFunction`](https://github.com/samchon/openapi/blob/master/src/structures/ILlmFunction.ts) + - [`ILlmSchema`](https://github.com/samchon/openapi/blob/master/src/structures/ILlmSchema.ts) + - [`LlmTypeChecker`](https://github.com/samchon/openapi/blob/master/src/utils/LlmTypeChecker.ts) + +> [!NOTE] +> +> Preparing playground website utilizing [`web-llm`](https://github.com/mlc-ai/web-llm). + +> [!TIP] +> +> LLM selects proper function and fill arguments. +> +> In nowadays, most LLM (Large Language Model) like OpenAI are supporting "function calling" feature. The "LLM function calling" means that LLM automatically selects a proper function and fills parameter values from conversation with the user (may by chatting text). +> +> https://platform.openai.com/docs/guides/function-calling + +### Execution +Actual function call execution is by yourself. + +LLM (Large Language Model) providers like OpenAI selects a proper function to call from the conversations with users, and fill arguments of it. However, function calling feature supported by LLM providers do not perform the function call execution. The actual execution responsibility is on you. + +In `@samchon/openapi`, you can execute the LLM function calling by `HttpLlm.execute()` (or `HttpLlm.propagate()`) function. Here is an example code executing the LLM function calling through the `HttpLlm.execute()` function. As you can see, to execute the LLM function call, you have to deliver these informations: + + - Connection info to the HTTP server + - Application of the LLM fuction calling + - LLM function schema to call + - Arguments for the function call (maybe composed by LLM) + +```typescript +import { + HttpLlm, + IHttpLlmApplication, + IHttpLlmFunction, + OpenApi, + OpenApiV3, + OpenApiV3_1, + SwaggerV2, +} from "@samchon/openapi"; +import fs from "fs"; +import typia from "typia"; +import { v4 } from "uuid"; + +const main = async (): Promise => { + // read swagger document and validate it + const swagger: + | SwaggerV2.IDocument + | OpenApiV3.IDocument + | OpenApiV3_1.IDocument = JSON.parse( + await fs.promises.readFile("swagger.json", "utf8"), + ); + typia.assert(swagger); // recommended + + // convert to emended OpenAPI document, + // and compose LLM function calling application + const document: OpenApi.IDocument = OpenApi.convert(swagger); + const application: IHttpLlmApplication = HttpLlm.application(document); + + // Let's imagine that LLM has selected a function to call + const func: IHttpLlmFunction | undefined = application.functions.find( + // (f) => f.name === "llm_selected_fuction_name" + (f) => f.path === "/bbs/{section}/articles/{id}" && f.method === "put", + ); + if (func === undefined) throw new Error("No matched function exists."); + + // actual execution is by yourself + const article = await HttpLlm.execute({ + connection: { + host: "http://localhost:3000", + }, + application, + function: func, + arguments: [ + "general", + v4(), + { + title: "Hello, world!", + body: "Let's imagine that this argument is composed by LLM.", + thumbnail: null, + }, + ], + }); + console.log("article", article); +}; +main().catch(console.error); +``` + +### Keyword Parameter +Combine parameters into single object. + +If you configure `keyword` option when composing the LLM (Large Language Model) function calling appliation, every parameters of OpenAPI operations would be combined to a single object type in the LLM funtion calling schema. This strategy is loved in many A.I. Chatbot developers, because LLM tends to a little professional in the single parameter function case. + +Also, do not worry about the function call execution case. You don't need to resolve the keyworded parameter manually. The `HttpLlm.execute()` and `HttpLlm.propagate()` functions will resolve the keyworded parameter automatically by analyzing the `IHttpLlmApplication.options` property. + +```typescript +import { + HttpLlm, + IHttpLlmApplication, + IHttpLlmFunction, + OpenApi, + OpenApiV3, + OpenApiV3_1, + SwaggerV2, +} from "@samchon/openapi"; +import fs from "fs"; +import typia from "typia"; +import { v4 } from "uuid"; + +const main = async (): Promise => { + // read swagger document and validate it + const swagger: + | SwaggerV2.IDocument + | OpenApiV3.IDocument + | OpenApiV3_1.IDocument = JSON.parse( + await fs.promises.readFile("swagger.json", "utf8"), + ); + typia.assert(swagger); // recommended + + // convert to emended OpenAPI document, + // and compose LLM function calling application + const document: OpenApi.IDocument = OpenApi.convert(swagger); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: true, + }); + + // Let's imagine that LLM has selected a function to call + const func: IHttpLlmFunction | undefined = application.functions.find( + // (f) => f.name === "llm_selected_fuction_name" + (f) => f.path === "/bbs/{section}/articles/{id}" && f.method === "put", + ); + if (func === undefined) throw new Error("No matched function exists."); + + // actual execution is by yourself + const article = await HttpLlm.execute({ + connection: { + host: "http://localhost:3000", + }, + application, + function: func, + arguments: [ + // one single object with key-value paired + { + section: "general", + id: v4(), + query: { + language: "en-US", + format: "markdown", + }, + body: { + title: "Hello, world!", + body: "Let's imagine that this argument is composed by LLM.", + thumbnail: null, + }, + }, + ], + }); + console.log("article", article); +}; +main().catch(console.error); +``` + +### Separation +Arguments from both Human and LLM sides. + +When composing parameter arguments through the LLM (Large Language Model) function calling, there can be a case that some parameters (or nested properties) must be composed not by LLM, but by Human. File uploading feature, or sensitive information like secret key (password) cases are the representative examples. + +In that case, you can configure the LLM function calling schemas to exclude such Human side parameters (or nested properties) by `IHttpLlmApplication.options.separate` property. Instead, you have to merge both Human and LLM composed parameters into one by calling the `HttpLlm.mergeParameters()` before the LLM function call execution of `HttpLlm.execute()` function. + +Here is the example code separating the file uploading feature from the LLM function calling schema, and combining both Human and LLM composed parameters into one before the LLM function call execution. + +```typescript +import { + HttpLlm, + IHttpLlmApplication, + IHttpLlmFunction, + LlmTypeChecker, + OpenApi, + OpenApiV3, + OpenApiV3_1, + SwaggerV2, +} from "@samchon/openapi"; +import fs from "fs"; +import typia from "typia"; +import { v4 } from "uuid"; + +const main = async (): Promise => { + // read swagger document and validate it + const swagger: + | SwaggerV2.IDocument + | OpenApiV3.IDocument + | OpenApiV3_1.IDocument = JSON.parse( + await fs.promises.readFile("swagger.json", "utf8"), + ); + typia.assert(swagger); // recommended + + // convert to emended OpenAPI document, + // and compose LLM function calling application + const document: OpenApi.IDocument = OpenApi.convert(swagger); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: false, + separate: (schema) => + LlmTypeChecker.isString(schema) && schema.contentMediaType !== undefined, + }); + + // Let's imagine that LLM has selected a function to call + const func: IHttpLlmFunction | undefined = application.functions.find( + // (f) => f.name === "llm_selected_fuction_name" + (f) => f.path === "/bbs/articles/{id}" && f.method === "put", + ); + if (func === undefined) throw new Error("No matched function exists."); + + // actual execution is by yourself + const article = await HttpLlm.execute({ + connection: { + host: "http://localhost:3000", + }, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + // LLM composed parameter values + "general", + v4(), + { + language: "en-US", + format: "markdown", + }, + { + title: "Hello, world!", + content: "Let's imagine that this argument is composed by LLM.", + }, + ], + human: [ + // Human composed parameter values + { thumbnail: "https://example.com/thumbnail.jpg" }, + ], + }), + }); + console.log("article", article); +}; +main().catch(console.error); +``` \ No newline at end of file diff --git a/package.json b/package.json index 786533f..b63cf87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@samchon/openapi", - "version": "0.4.9", + "version": "1.0.0", "description": "OpenAPI definitions and converters for 'typia' and 'nestia'.", "main": "./lib/index.js", "module": "./lib/index.mjs", @@ -32,21 +32,31 @@ }, "homepage": "https://github.com/samchon/openapi", "devDependencies": { + "@nestia/core": "^3.12.0", "@nestia/e2e": "^0.7.0", + "@nestia/fetcher": "^3.12.0", + "@nestia/sdk": "^3.12.0", + "@nestjs/common": "^10.4.1", + "@nestjs/core": "^10.4.1", + "@nestjs/platform-express": "^10.4.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/js-yaml": "^4.0.9", "@types/node": "^20.12.7", + "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "js-yaml": "^4.1.0", + "nestia": "^6.0.1", "prettier": "^3.2.5", "rimraf": "^5.0.5", "rollup": "^4.18.1", + "source-map-support": "^0.5.21", "ts-patch": "^3.2.1", "typescript": "^5.5.3", "typescript-transform-paths": "^3.4.7", - "typia": "^6.0.0" + "typia": "^6.9.0", + "uuid": "^10.0.0" }, "files": [ "lib", diff --git a/src/HttpLlm.ts b/src/HttpLlm.ts new file mode 100644 index 0000000..ab625a3 --- /dev/null +++ b/src/HttpLlm.ts @@ -0,0 +1,245 @@ +import { HttpMigration } from "./HttpMigration"; +import { OpenApi } from "./OpenApi"; +import { HttpLlmConverter } from "./converters/HttpLlmConverter"; +import { HttpLlmFunctionFetcher } from "./http/HttpLlmFunctionFetcher"; +import { IHttpConnection } from "./structures/IHttpConnection"; +import { IHttpLlmApplication } from "./structures/IHttpLlmApplication"; +import { IHttpLlmFunction } from "./structures/IHttpLlmFunction"; +import { IHttpMigrateApplication } from "./structures/IHttpMigrateApplication"; +import { IHttpResponse } from "./structures/IHttpResponse"; +import { ILlmFunction } from "./structures/ILlmFunction"; +import { ILlmSchema } from "./structures/ILlmSchema"; +import { LlmDataMerger } from "./utils/LlmDataMerger"; + +/** + * LLM function calling application composer from OpenAPI document. + * + * `HttpLlm` is a module for composing LLM (Large Language Model) function calling + * application from the {@link OpenApi.IDocument OpenAPI document}, and also for + * LLM function call execution and parameter merging. + * + * At first, you can construct the LLM function calling application by the + * {@link HttpLlm.application HttpLlm.application()} function. And then the LLM + * has selected a {@link IHttpLlmFunction function} to call and composes its + * arguments, you can execute the function by + * {@link HttpLlm.execute HttpLlm.execute()} or + * {@link HttpLlm.propagate HttpLlm.propagate()}. + * + * By the way, if you have configured the {@link IHttpLlmApplication.IOptions.separate} + * option to separate the parameters into human and LLM sides, you can merge these + * human and LLM sides' parameters into one through + * {@link HttpLlm.mergeParameters HttpLlm.mergeParameters()} before the actual LLM + * function call execution. + * + * @author Jeongho Nam - https://github.com/samchon + */ +export namespace HttpLlm { + /* ----------------------------------------------------------- + COMPOSERS + ----------------------------------------------------------- */ + /** + * Convert OpenAPI document to LLM function calling application. + * + * Converts {@link OpenApi.IDocument OpenAPI document} or + * {@link IHttpMigrateApplication migrated application} to the + * {@link IHttpLlmApplication LLM function calling application}. Every + * {@link OpenApi.IOperation API operations} in the OpenAPI document are converted + * to the {@link IHttpLlmFunction LLM function} type, and they would be used for + * the LLM function calling. + * + * If you have configured the {@link IHttpLlmApplication.IOptions.separate} option, + * every parameters in the {@link IHttpLlmFunction} would be separated into both + * human and LLM sides. In that case, you can merge these human and LLM sides' + * parameters into one through {@link HttpLlm.mergeParameters} before the actual + * LLM function call execution. + * + * Additionally, if you have configured the {@link IHttpLlmApplication.IOptions.keyword} + * as `true`, the number of {@link IHttpLlmFunction.parameters} are always 1 and the + * first parameter type is always {@link ILlmSchema.IObject}. I recommend this option + * because LLM can understand the keyword arguments more easily. + * + * @param document Target OpenAPI document to convert (or migrate application) + * @param options Options for the LLM function calling application conversion + * @returns LLM function calling application + */ + export const application = < + Schema extends ILlmSchema, + Operation extends OpenApi.IOperation, + >( + document: + | OpenApi.IDocument + | IHttpMigrateApplication, + options?: Partial, + ): IHttpLlmApplication => { + // MIGRATE + if ((document as OpenApi.IDocument)["x-samchon-emended"] === true) + document = HttpMigration.application( + document as OpenApi.IDocument, + ); + return HttpLlmConverter.compose( + document as IHttpMigrateApplication, + { + keyword: options?.keyword ?? false, + separate: options?.separate ?? null, + }, + ); + }; + + /** + * Convert JSON schema to LLM schema. + * + * Converts {@link OpenApi.IJsonSchema JSON schema} to {@link ILlmSchema LLM schema}. + * + * By the way, if the target JSON schema has some recursive references, the + * conversion would be failed and `null` value would be returned. It's because + * the LLM schema does not support the reference type embodied by the + * {@link OpenApi.IJsonSchema.IReference} type. + * + * @param props Schema to convert and components to refer + * @returns LLM schema or null value + */ + export const schema = (props: { + components: OpenApi.IComponents; + schema: OpenApi.IJsonSchema; + }): ILlmSchema | null => HttpLlmConverter.schema(props); + + /* ----------------------------------------------------------- + FETCHERS + ----------------------------------------------------------- */ + /** + * Properties for the LLM function call. + */ + export interface IFetchProps { + /** + * Application of the LLM function calling. + */ + application: IHttpLlmApplication; + + /** + * LLM function schema to call. + */ + function: IHttpLlmFunction; + + /** + * Connection info to the HTTP server. + */ + connection: IHttpConnection; + + /** + * Arguments for the function call. + */ + arguments: any[]; + } + + /** + * Execute the LLM function call. + * + * `HttmLlm.execute()` is a function executing the target + * {@link OpenApi.IOperation API endpoint} with with the connection information + * and arguments composed by Large Language Model like OpenAI (+human sometimes). + * + * By the way, if you've configured the {@link IHttpLlmApplication.IOptions.separate}, + * so that the parameters are separated to human and LLM sides, you have to merge + * these humand and LLM sides' parameters into one through + * {@link HttpLlm.mergeParameters} function. + * + * About the {@link IHttpLlmApplication.IOptions.keyword} option, don't worry anything. + * This `HttmLlm.execute()` function will automatically recognize the keyword arguments + * and convert them to the proper sequence. + * + * For reference, if the target API endpoinnt responds none 200/201 status, this + * would be considered as an error and the {@link HttpError} would be thrown. + * Otherwise you don't want such rule, you can use the {@link HttpLlm.propagate} + * function instead. + * + * @param props Properties for the LLM function call + * @returns Return value (response body) from the API endpoint + * @throws HttpError when the API endpoint responds none 200/201 status + */ + export const execute = (props: IFetchProps): Promise => + HttpLlmFunctionFetcher.execute(props); + + /** + * Propagate the LLM function call. + * + * `HttmLlm.propagate()` is a function propagating the target + * {@link OpenApi.IOperation API endpoint} with with the connection information + * and arguments composed by Large Language Model like OpenAI (+human sometimes). + * + * By the way, if you've configured the {@link IHttpLlmApplication.IOptions.separate}, + * so that the parameters are separated to human and LLM sides, you have to merge + * these humand and LLM sides' parameters into one through + * {@link HttpLlm.mergeParameters} function. + * + * About the {@link IHttpLlmApplication.IOptions.keyword} option, don't worry anything. + * This `HttmLlm.propagate()` function will automatically recognize the keyword arguments + * and convert them to the proper sequence. + * + * For reference, the propagation means always returning the response from the API + * endpoint, even if the status is not 200/201. This is useful when you want to + * handle the response by yourself. + * + * @param props Properties for the LLM function call + * @returns Response from the API endpoint + * @throws Error only when the connection is failed + */ + export const propagate = (props: IFetchProps): Promise => + HttpLlmFunctionFetcher.propagate(props); + + /* ----------------------------------------------------------- + MERGERS + ----------------------------------------------------------- */ + /** + * Properties for the parameters' merging. + */ + export interface IMergeProps { + /** + * Metadata of the target function. + */ + function: ILlmFunction; + + /** + * Arguments composed by the LLM. + */ + llm: unknown[]; + + /** + * Arguments composed by the human. + */ + human: unknown[]; + } + + /** + * Merge the parameters. + * + * If you've configured the {@link IHttpLlmApplication.IOptions.separate} option, + * so that the parameters are separated to human and LLM sides, you can merge these + * humand and LLM sides' parameters into one through this `HttpLlm.mergeParameters()` + * function before the actual LLM function call execution. + * + * On contrary, if you've not configured the + * {@link IHttpLlmApplication.IOptions.separate} option, this function would throw + * an error. + * + * @param props Properties for the parameters' merging + * @returns Merged parameter values + */ + export const mergeParameters = (props: IMergeProps): unknown[] => + LlmDataMerger.parameters(props); + + /** + * Merge two values. + * + * If both values are objects, then combines them in the properties level. + * + * Otherwise, returns the latter value if it's not null, otherwise the former value. + * + * - `return (y ?? x)` + * + * @param x Value X to merge + * @param y Value Y to merge + * @returns Merged value + */ + export const mergeValue = (x: unknown, y: unknown): unknown => + LlmDataMerger.value(x, y); +} diff --git a/src/HttpMigration.ts b/src/HttpMigration.ts new file mode 100644 index 0000000..653808c --- /dev/null +++ b/src/HttpMigration.ts @@ -0,0 +1,158 @@ +import { OpenApi } from "./OpenApi"; +import { MigrateConverter } from "./converters/MigrateConverter"; +import { HttpMigrateRouteFetcher } from "./http/HttpMigrateRouteFetcher"; +import { IHttpConnection } from "./structures/IHttpConnection"; +import { IHttpMigrateApplication } from "./structures/IHttpMigrateApplication"; +import { IHttpMigrateRoute } from "./structures/IHttpMigrateRoute"; +import { IHttpResponse } from "./structures/IHttpResponse"; + +/** + * HTTP migration application composer from OpenAPI document. + * + * `HttpMigration` is a module for composing HTTP migration application from the + * {@link OpenApi.IDocument OpenAPI document}. It is designed for helping the OpenAPI + * generator libraries, which converts {@link OpenApi.IOperation OpenAPI operations} to + * an RPC (Remote Procedure Call) function. + * + * The key feature of the `HttpModule` is the {@link HttpMigration.application} function. + * It converts the {@link OpenApi.IOperation OpenAPI operations} to the + * {@link IHttpMigrateRoute HTTP migration route}, and it normalizes the OpenAPI operations + * to the RPC function calling suitable route structure. + * + * The other functions, {@link HttpMigration.execute} and {@link HttpMigration.propagate}, + * are for executing the HTTP request to the HTTP server. The {@link HttpMigration.execute} + * function returns the response body from the API endpoint when the status code is `200` + * or `201`. Otherwise, it throws an {@link HttpError} when the status code is not `200` + * or `201`. The {@link HttpMigration.propagate} function returns the response information + * from the API endpoint, including the status code, headers, and response body. + * + * The {@link HttpLlm} module is a good example utilizing this `HttpMigration` module + * for composing RPC function calling application. The {@link HttpLlm} module composes + * LLM (Large Language Model) function calling application from the OpenAPI document + * bypassing through the {@link IHttpLlmApplication} type. + * + * @author Jeongho Nam - https://github.com/samchon + */ +export namespace HttpMigration { + /* ----------------------------------------------------------- + COMPOSER + ----------------------------------------------------------- */ + /** + * Convert HTTP migration application from OpenAPI document. + * + * `HttpMigration.application()` is a function converting the + * {@link OpenApi.IDocument OpenAPI document} and its {@link OpenApi.IOperation operations} + * to the {@link IHttpMigrateApplication HTTP migration application}. + * + * The HTTP migration application is designed for helping the OpenAPI generator + * libraries, which converts OpenAPI operations to an RPC (Remote Procedure Call) + * function. To support the OpenAPI generator libraries, {@link IHttpMigrateRoute} + * takes below normalization rules: + * + * - Path parameters are separated to atomic level. + * - Query parameters are binded into one object. + * - Header parameters are binded into one object. + * - Allow only below HTTP methods + * - `head` + * - `get` + * - `post` + * - `put` + * - `patch` + * - `delete` + * - Allow only below content media types + * - `application/json` + * - `application/x-www-form-urlencoded` + * - `multipart/form-data` + * - `text/plain` + * + * If there're some {@link OpenApi.IOperation API operations} which canont adjust + * the above rules or there're some logically insensible, these operation would be + * failed to migrate and registered into the {@link IHttpMigrateApplication.errors}. + * + * @param document OpenAPI document to migrate. + * @returns Migrated application. + */ + export const application = < + Schema extends OpenApi.IJsonSchema = OpenApi.IJsonSchema, + Operation extends OpenApi.IOperation = OpenApi.IOperation, + >( + document: OpenApi.IDocument, + ): IHttpMigrateApplication => + MigrateConverter.convert(document); + + /** + * Properties for the request to the HTTP server. + */ + export interface IFetchProps { + /** + * Connection info to the HTTP server. + */ + connection: IHttpConnection; + + /** + * Route information for the migration. + */ + route: IHttpMigrateRoute; + + /** + * Path parameters. + * + * Path parameters with sequenced array or key-value paired object. + */ + parameters: + | Array + | Record; + + /** + * Query parameters as a key-value paired object. + */ + query?: object | undefined; + + /** + * Request body data. + */ + body?: object | undefined; + } + + /* ----------------------------------------------------------- + FETCHERS + ----------------------------------------------------------- */ + /** + * Execute the HTTP request. + * + * `HttpMigration.execute()` is a function executing the HTTP request to the HTTP server. + * + * It returns the response body from the API endpoint when the status code is `200` + * or `201`. Otherwise, it throws an {@link HttpError} when the status code is not + * `200` or `201`. + * + * If you want to get more information than the response body, or get the detailed + * response information even when the status code is `200` or `201`, use the + * {@link HttpMigration.propagate} function instead. + * + * @param props Properties for the request. + * @returns Return value (response body) from the API endpoint. + * @throws HttpError when the API endpoint responds none 200/201 status. + */ + export const execute = (props: IFetchProps): Promise => + HttpMigrateRouteFetcher.execute(props); + + /** + * Propagate the HTTP request. + * + * `HttpMigration.propagate()` is a function propagating the request to the HTTP server. + * + * It returns the response information from the API endpoint, including the status code, + * headers, and response body. + * + * Even if the status code is not `200` or `201`, this function + * would return the response information. By the way, if the connection to the HTTP server + * is failed, this function would throw an {@link Error}. + * + * @param props Properties for the request. + * @returns Response from the API endpoint. + * @throws Error when the connection is failed. + */ + export const propagate = (props: IFetchProps): Promise => + HttpMigrateRouteFetcher.propagate(props); +} diff --git a/src/OpenApi.ts b/src/OpenApi.ts index eb013dd..f9ffae3 100644 --- a/src/OpenApi.ts +++ b/src/OpenApi.ts @@ -1,13 +1,11 @@ -import { IMigrateDocument } from "./IMigrateDocument"; import { OpenApiV3 } from "./OpenApiV3"; import { OpenApiV3_1 } from "./OpenApiV3_1"; import { SwaggerV2 } from "./SwaggerV2"; -import { MigrateConverter } from "./internal/MigrateConverter"; -import { OpenApiV3Converter } from "./internal/OpenApiV3Converter"; -import { OpenApiV3Downgrader } from "./internal/OpenApiV3Downgrader"; -import { OpenApiV3_1Converter } from "./internal/OpenApiV3_1Converter"; -import { SwaggerV2Converter } from "./internal/SwaggerV2Converter"; -import { SwaggerV2Downgrader } from "./internal/SwaggerV2Downgrader"; +import { OpenApiV3Converter } from "./converters/OpenApiV3Converter"; +import { OpenApiV3Downgrader } from "./converters/OpenApiV3Downgrader"; +import { OpenApiV3_1Converter } from "./converters/OpenApiV3_1Converter"; +import { SwaggerV2Converter } from "./converters/SwaggerV2Converter"; +import { SwaggerV2Downgrader } from "./converters/SwaggerV2Downgrader"; /** * Emended OpenAPI v3.1 definition used by `typia` and `nestia`. @@ -134,25 +132,6 @@ export namespace OpenApi { throw new TypeError("Unrecognized Swagger/OpenAPI version."); } - /** - * Convert to migrate document. - * - * Convert the given OpenAPI document to {@link IMigrateDocument}, that is - * useful for OpenAPI generator library which makes RPC (Remote Procedure Call) - * functions for the Restful API operation. - * - * @param document OpenAPI document to migrate - * @returns Migrated document - */ - export function migrate< - Schema extends IJsonSchema = IJsonSchema, - Operation extends IOperation = IOperation, - >( - document: IDocument, - ): IMigrateDocument { - return MigrateConverter.convert(document); - } - /* ----------------------------------------------------------- PATH ITEMS ----------------------------------------------------------- */ @@ -1052,7 +1031,7 @@ export namespace OpenApi { /** * List of the union types. */ - oneOf: Exclude[]; + oneOf: Exclude>[]; /** * Discriminator info of the union type. diff --git a/src/converters/HttpLlmConverter.ts b/src/converters/HttpLlmConverter.ts new file mode 100644 index 0000000..c863d74 --- /dev/null +++ b/src/converters/HttpLlmConverter.ts @@ -0,0 +1,284 @@ +import { OpenApi } from "../OpenApi"; +import { IHttpLlmApplication } from "../structures/IHttpLlmApplication"; +import { IHttpLlmFunction } from "../structures/IHttpLlmFunction"; +import { IHttpMigrateApplication } from "../structures/IHttpMigrateApplication"; +import { IHttpMigrateRoute } from "../structures/IHttpMigrateRoute"; +import { ILlmSchema } from "../structures/ILlmSchema"; +import { LlmSchemaSeparator } from "../utils/LlmSchemaSeparator"; +import { LlmTypeChecker } from "../utils/LlmTypeChecker"; +import { OpenApiTypeChecker } from "../utils/OpenApiTypeChecker"; +import { OpenApiV3Downgrader } from "./OpenApiV3Downgrader"; + +export namespace HttpLlmConverter { + export const compose = ( + migrate: IHttpMigrateApplication, + options: IHttpLlmApplication.IOptions, + ): IHttpLlmApplication => { + // COMPOSE FUNCTIONS + const errors: IHttpLlmApplication.IError[] = migrate.errors.map((e) => ({ + method: e.method, + path: e.path, + messages: e.messages, + operation: () => e.operation(), + route: () => undefined, + })); + const functions: IHttpLlmFunction[] = migrate.routes + .map((route) => { + if (route.method === "head") return null; + const func: IHttpLlmFunction | null = composeFunction(options)( + migrate.document().components, + )(route); + if (func === null) + errors.push({ + method: route.method, + path: route.path, + messages: ["Failed to escape $ref"], + operation: () => route.operation(), + route: () => route, + }); + return func; + }) + .filter((v): v is IHttpLlmFunction => v !== null); + return { + openapi: "3.0.3", + functions, + errors, + options, + }; + }; + + export const schema = (props: { + components: OpenApi.IComponents; + schema: OpenApi.IJsonSchema; + }): ILlmSchema | null => { + const resolved: OpenApi.IJsonSchema | null = escape({ + components: props.components, + visited: new Set(), + input: props.schema, + }); + if (resolved === null) return null; + const downgraded: ILlmSchema = OpenApiV3Downgrader.downgradeSchema({ + original: {}, + downgraded: {}, + })(resolved) as ILlmSchema; + LlmTypeChecker.visit(downgraded, (schema) => { + if ( + LlmTypeChecker.isOneOf(schema) && + (schema as any).discriminator !== undefined + ) + delete (schema as any).discriminator; + }); + return downgraded; + }; +} + +const composeFunction = + (options: IHttpLlmApplication.IOptions) => + (components: OpenApi.IComponents) => + (route: IHttpMigrateRoute): IHttpLlmFunction | null => { + // CAST SCHEMA TYPES + const cast = (s: OpenApi.IJsonSchema) => + HttpLlmConverter.schema({ + components, + schema: s, + }); + const output: ILlmSchema | null | undefined = + route.success && route.success ? cast(route.success.schema) : undefined; + if (output === null) return null; + const properties: [string, ILlmSchema | null][] = [ + ...route.parameters.map((p) => ({ + key: p.key, + schema: { + ...p.schema, + title: p.parameter().title ?? p.schema.title, + description: p.parameter().description ?? p.schema.description, + }, + })), + ...(route.query + ? [ + { + key: route.query.key, + schema: { + ...route.query.schema, + title: route.query.title() ?? route.query.schema.title, + description: + route.query.description() ?? route.query.schema.description, + }, + }, + ] + : []), + ...(route.body + ? [ + { + key: route.body.key, + schema: { + ...route.body.schema, + description: + route.body.description() ?? route.body.schema.description, + }, + }, + ] + : []), + ].map((o) => [o.key, cast(o.schema)]); + if (properties.some(([_k, v]) => v === null)) return null; + + // COMPOSE PARAMETERS + const parameters: ILlmSchema[] = options.keyword + ? [ + { + type: "object", + properties: Object.fromEntries( + properties as [string, ILlmSchema][], + ), + }, + ] + : properties.map(([_k, v]) => v!); + const operation: OpenApi.IOperation = route.operation(); + + // FINALIZATION + return { + method: route.method as "get", + path: route.path, + name: route.accessor.join("_"), + strict: true, + parameters, + separated: options.separate + ? LlmSchemaSeparator.parameters({ + parameters, + predicator: options.separate, + }) + : undefined, + output: output + ? (OpenApiV3Downgrader.downgradeSchema({ + original: {}, + downgraded: {}, + })(output as any) as ILlmSchema) + : undefined, + description: (() => { + if (operation.summary && operation.description) { + return operation.description.startsWith(operation.summary) + ? operation.description + : [ + operation.summary, + operation.summary.endsWith(".") ? "" : ".", + "\n\n", + operation.description, + ].join(""); + } + return operation.description ?? operation.summary; + })(), + route: () => route, + operation: () => operation, + }; + }; + +const escape = (props: { + components: OpenApi.IComponents; + visited: Set; + input: OpenApi.IJsonSchema; +}): OpenApi.IJsonSchema | null => { + if (OpenApiTypeChecker.isReference(props.input)) { + // REFERENCE + const name: string = props.input.$ref.split("#/components/schemas/")[1]; + const target: OpenApi.IJsonSchema | undefined = + props.components.schemas?.[name]; + if (!target) return null; + else if (props.visited.has(name)) return null; + return escape({ + components: props.components, + visited: new Set([...props.visited, name]), + input: target, + }); + } else if (OpenApiTypeChecker.isOneOf(props.input)) { + // ONE-OF + const oneOf: Array = props.input.oneOf.map( + (schema) => + escape({ + ...props, + input: schema, + })!, + ); + if (oneOf.some((v) => v === null)) return null; + return { + ...props.input, + oneOf: oneOf as OpenApi.IJsonSchema[], + }; + } else if (OpenApiTypeChecker.isObject(props.input)) { + // OBJECT + const properties: Array<[string, OpenApi.IJsonSchema | null]> | undefined = + props.input.properties + ? Object.entries(props.input.properties).map( + ([key, value]) => + [ + key, + escape({ + ...props, + input: value, + }), + ] as const, + ) + : undefined; + const additionalProperties: + | OpenApi.IJsonSchema + | null + | boolean + | undefined = props.input.additionalProperties + ? typeof props.input.additionalProperties === "object" && + props.input.additionalProperties !== null + ? escape({ + ...props, + input: props.input.additionalProperties, + }) + : props.input.additionalProperties + : undefined; + if (properties && properties.some(([_k, v]) => v === null)) return null; + else if (additionalProperties === null) return null; + return { + ...props.input, + properties: properties + ? Object.fromEntries( + properties.filter(([_k, v]) => !!v) as Array< + [string, OpenApi.IJsonSchema] + >, + ) + : undefined, + additionalProperties, + }; + } else if (OpenApiTypeChecker.isTuple(props.input)) { + // TUPLE + const prefixItems: Array = + props.input.prefixItems.map((schema) => + escape({ + ...props, + input: schema, + }), + ); + const additionalItems: OpenApi.IJsonSchema | null | boolean | undefined = + typeof props.input.additionalItems === "object" && + props.input.additionalItems !== null + ? escape({ + ...props, + input: props.input.additionalItems, + }) + : props.input.additionalItems; + if (prefixItems.some((v) => v === null)) return null; + else if (additionalItems === null) return null; + return { + ...props.input, + prefixItems: prefixItems as OpenApi.IJsonSchema[], + additionalItems, + }; + } else if (OpenApiTypeChecker.isArray(props.input)) { + // ARRAY + const items: OpenApi.IJsonSchema | null = escape({ + ...props, + input: props.input.items, + }); + if (items === null) return null; + return { + ...props.input, + items, + }; + } + return props.input; +}; diff --git a/src/internal/MigrateConverter.ts b/src/converters/MigrateConverter.ts similarity index 69% rename from src/internal/MigrateConverter.ts rename to src/converters/MigrateConverter.ts index b4b352a..120ebf5 100644 --- a/src/internal/MigrateConverter.ts +++ b/src/converters/MigrateConverter.ts @@ -1,9 +1,9 @@ -import { IMigrateRoute } from "../IMigrateRoute"; -import { IMigrateDocument } from "../IMigrateDocument"; import { OpenApi } from "../OpenApi"; +import { IHttpMigrateApplication } from "../structures/IHttpMigrateApplication"; +import { IHttpMigrateRoute } from "../structures/IHttpMigrateRoute"; import { StringUtil } from "../utils/StringUtil"; -import { MigrateRouteConverter } from "./MigrateRouteConverter"; import { MigrateRouteAccessor } from "./MigrateRouteAccessor"; +import { MigrateRouteConverter } from "./MigrateRouteConverter"; export namespace MigrateConverter { export const convert = < @@ -11,9 +11,9 @@ export namespace MigrateConverter { Operation extends OpenApi.IOperation, >( document: OpenApi.IDocument, - ): IMigrateDocument => { - const errors: IMigrateDocument.IError[] = []; - const entire: Array | null> = + ): IHttpMigrateApplication => { + const errors: IHttpMigrateApplication.IError[] = []; + const entire: Array | null> = Object.entries({ ...(document.paths ?? {}), ...(document.webhooks ?? {}), @@ -23,14 +23,14 @@ export namespace MigrateConverter { .filter((method) => collection[method] !== undefined) .map((method) => { const operation: Operation = collection[method]!; - const migrated: IMigrateRoute | string[] = + const migrated: IHttpMigrateRoute | string[] = MigrateRouteConverter.convert({ document, method, path, emendedPath: StringUtil.reJoinWithDecimalParameters(path), operation, - }) as IMigrateRoute | string[]; + }) as IHttpMigrateRoute | string[]; if (Array.isArray(migrated)) { errors.push({ method, @@ -44,11 +44,12 @@ export namespace MigrateConverter { }), ) .flat(); - const operations: IMigrateRoute[] = entire.filter( - (o): o is IMigrateRoute => !!o, + const operations: IHttpMigrateRoute[] = entire.filter( + (o): o is IHttpMigrateRoute => !!o, ); MigrateRouteAccessor.overwrite(operations); return { + document: () => document, routes: operations, errors, }; diff --git a/src/internal/MigrateRouteAccessor.ts b/src/converters/MigrateRouteAccessor.ts similarity index 91% rename from src/internal/MigrateRouteAccessor.ts rename to src/converters/MigrateRouteAccessor.ts index 75821fd..76053d5 100644 --- a/src/internal/MigrateRouteAccessor.ts +++ b/src/converters/MigrateRouteAccessor.ts @@ -1,5 +1,5 @@ -import { IMigrateRoute } from "../IMigrateRoute"; import { OpenApi } from "../OpenApi"; +import { IHttpMigrateRoute } from "../structures/IHttpMigrateRoute"; import { Escaper } from "../utils/Escaper"; import { MapUtil } from "../utils/MapUtil"; import { StringUtil } from "../utils/StringUtil"; @@ -9,7 +9,7 @@ export namespace MigrateRouteAccessor { Schema extends OpenApi.IJsonSchema, Operation extends OpenApi.IOperation, >( - routes: IMigrateRoute[], + routes: IHttpMigrateRoute[], ): void => { const dict: Map> = collect((op) => op.emendedPath @@ -50,10 +50,10 @@ export namespace MigrateRouteAccessor { Schema extends OpenApi.IJsonSchema, Operation extends OpenApi.IOperation, >( - getter: (r: IMigrateRoute) => string[], + getter: (r: IHttpMigrateRoute) => string[], ) => ( - routes: IMigrateRoute[], + routes: IHttpMigrateRoute[], ): Map> => { const dict: Map> = new Map(); for (const r of routes) { @@ -94,7 +94,7 @@ export namespace MigrateRouteAccessor { Schema extends OpenApi.IJsonSchema, Operation extends OpenApi.IOperation, >( - op: IMigrateRoute, + op: IHttpMigrateRoute, ): string => { const method = op.method === "delete" ? "erase" : op.method; if (op.parameters.length === 0) return method; @@ -117,7 +117,7 @@ export namespace MigrateRouteAccessor { Schema extends OpenApi.IJsonSchema, Operation extends OpenApi.IOperation, > { - route: IMigrateRoute; + route: IHttpMigrateRoute; alias: string; } } diff --git a/src/internal/MigrateRouteConverter.ts b/src/converters/MigrateRouteConverter.ts similarity index 95% rename from src/internal/MigrateRouteConverter.ts rename to src/converters/MigrateRouteConverter.ts index 900df8e..362a2c4 100644 --- a/src/internal/MigrateRouteConverter.ts +++ b/src/converters/MigrateRouteConverter.ts @@ -1,7 +1,7 @@ -import { IMigrateRoute } from "../IMigrateRoute"; import { OpenApi } from "../OpenApi"; -import { OpenApiTypeChecker } from "../OpenApiTypeChecker"; +import { IHttpMigrateRoute } from "../structures/IHttpMigrateRoute"; import { Escaper } from "../utils/Escaper"; +import { OpenApiTypeChecker } from "../utils/OpenApiTypeChecker"; import { StringUtil } from "../utils/StringUtil"; export namespace MigrateRouteConverter { @@ -12,11 +12,11 @@ export namespace MigrateRouteConverter { emendedPath: string; operation: OpenApi.IOperation; } - export const convert = (props: IProps): IMigrateRoute | string[] => { + export const convert = (props: IProps): IHttpMigrateRoute | string[] => { //---- // REQUEST AND RESPONSE BODY //---- - const body: false | null | IMigrateRoute.IBody = emplaceBodySchema( + const body: false | null | IHttpMigrateRoute.IBody = emplaceBodySchema( "request", )((schema) => emplaceReference({ @@ -25,7 +25,7 @@ export namespace MigrateRouteConverter { schema, }), )(props.operation.requestBody); - const success: false | null | IMigrateRoute.IBody = emplaceBodySchema( + const success: false | null | IHttpMigrateRoute.IBody = emplaceBodySchema( "response", )((schema) => emplaceReference({ @@ -97,7 +97,7 @@ export namespace MigrateRouteConverter { description: () => elem.description, example: () => elem.example, examples: () => elem.examples, - }) satisfies IMigrateRoute.IHeaders; + }) satisfies IHttpMigrateRoute.IHeaders; if (objects.length === 1 && primitives.length === 0) return out(parameters[0]); @@ -220,7 +220,7 @@ export namespace MigrateRouteConverter { ); if (failures.length) return failures; - const parameters: IMigrateRoute.IParameter[] = ( + const parameters: IHttpMigrateRoute.IParameter[] = ( props.operation.parameters ?? [] ) .filter((p) => p.in === "path") @@ -261,8 +261,8 @@ export namespace MigrateRouteConverter { })), headers: headers || null, query: query || null, - body: body as IMigrateRoute.IBody | null, - success: success as IMigrateRoute.IBody | null, + body: body as IHttpMigrateRoute.IBody | null, + success: success as IHttpMigrateRoute.IBody | null, exceptions: Object.fromEntries( Object.entries(props.operation.responses ?? {}) .filter( @@ -290,9 +290,9 @@ export namespace MigrateRouteConverter { const writeRouteComment = (props: { operation: OpenApi.IOperation; - parameters: IMigrateRoute.IParameter[]; - query: IMigrateRoute.IQuery | null; - body: IMigrateRoute.IBody | null; + parameters: IHttpMigrateRoute.IParameter[]; + query: IHttpMigrateRoute.IQuery | null; + body: IHttpMigrateRoute.IBody | null; }): string => { const commentTags: string[] = []; const add = (text: string) => { @@ -355,7 +355,7 @@ export namespace MigrateRouteConverter { description?: string; content?: Partial>; // ISwaggerRouteBodyContent; "x-nestia-encrypted"?: boolean; - }): false | null | IMigrateRoute.IBody => { + }): false | null | IHttpMigrateRoute.IBody => { if (!meta?.content) return null; const entries: [string, OpenApi.IOperation.IMediaType][] = Object.entries( diff --git a/src/internal/OpenApiV3Converter.ts b/src/converters/OpenApiV3Converter.ts similarity index 100% rename from src/internal/OpenApiV3Converter.ts rename to src/converters/OpenApiV3Converter.ts diff --git a/src/internal/OpenApiV3Downgrader.ts b/src/converters/OpenApiV3Downgrader.ts similarity index 99% rename from src/internal/OpenApiV3Downgrader.ts rename to src/converters/OpenApiV3Downgrader.ts index 108a1ac..6c9a71d 100644 --- a/src/internal/OpenApiV3Downgrader.ts +++ b/src/converters/OpenApiV3Downgrader.ts @@ -1,6 +1,6 @@ import { OpenApi } from "../OpenApi"; -import { OpenApiTypeChecker } from "../OpenApiTypeChecker"; import { OpenApiV3 } from "../OpenApiV3"; +import { OpenApiTypeChecker } from "../utils/OpenApiTypeChecker"; export namespace OpenApiV3Downgrader { export interface IComponentsCollection { diff --git a/src/internal/OpenApiV3_1Converter.ts b/src/converters/OpenApiV3_1Converter.ts similarity index 100% rename from src/internal/OpenApiV3_1Converter.ts rename to src/converters/OpenApiV3_1Converter.ts diff --git a/src/internal/SwaggerV2Converter.ts b/src/converters/SwaggerV2Converter.ts similarity index 100% rename from src/internal/SwaggerV2Converter.ts rename to src/converters/SwaggerV2Converter.ts diff --git a/src/internal/SwaggerV2Downgrader.ts b/src/converters/SwaggerV2Downgrader.ts similarity index 99% rename from src/internal/SwaggerV2Downgrader.ts rename to src/converters/SwaggerV2Downgrader.ts index cdd08a4..7d70ad2 100644 --- a/src/internal/SwaggerV2Downgrader.ts +++ b/src/converters/SwaggerV2Downgrader.ts @@ -1,6 +1,6 @@ import { OpenApi } from "../OpenApi"; -import { OpenApiTypeChecker } from "../OpenApiTypeChecker"; import { SwaggerV2 } from "../SwaggerV2"; +import { OpenApiTypeChecker } from "../utils/OpenApiTypeChecker"; export namespace SwaggerV2Downgrader { export interface IComponentsCollection { diff --git a/src/http/HttpError.ts b/src/http/HttpError.ts new file mode 100644 index 0000000..fb0f90f --- /dev/null +++ b/src/http/HttpError.ts @@ -0,0 +1,85 @@ +/** + * HTTP Error. + * + * `HttpError` is a type of error class who've been thrown by the remote HTTP server. + * + * @author Jeongho Nam - https://github.com/samchon + */ +export class HttpError extends Error { + /** + * @internal + */ + private body_: any = NOT_YET; + + /** + * Initializer Constructor. + * + * @param method Method of the HTTP request. + * @param path Path of the HTTP request. + * @param status Status code from the remote HTTP server. + * @param message Error message from the remote HTTP server. + */ + public constructor( + public readonly method: + | "GET" + | "DELETE" + | "POST" + | "PUT" + | "PATCH" + | "HEAD", + public readonly path: string, + public readonly status: number, + public readonly headers: Record, + message: string, + ) { + super(message); + + // INHERITANCE POLYFILL + const proto: HttpError = new.target.prototype; + if (Object.setPrototypeOf) Object.setPrototypeOf(this, proto); + else (this as any).__proto__ = proto; + } + + /** + * `HttpError` to JSON. + * + * When you call `JSON.stringify()` function on current `HttpError` instance, + * this `HttpError.toJSON()` method would be automatically called. + * + * Also, if response body from the remote HTTP server forms a JSON object, + * this `HttpError.toJSON()` method would be useful because it returns the + * parsed JSON object about the {@link message} property. + * + * @template T Expected type of the response body. + * @returns JSON object of the `HttpError`. + */ + public toJSON(): HttpError.IProps { + if (this.body_ === NOT_YET) + try { + this.body_ = JSON.parse(this.message); + } catch { + this.body_ = this.message; + } + return { + method: this.method, + path: this.path, + status: this.status, + headers: this.headers, + message: this.body_, + }; + } +} +export namespace HttpError { + /** + * Returned type of {@link HttpError.toJSON} method. + */ + export interface IProps { + method: "GET" | "DELETE" | "POST" | "PUT" | "PATCH" | "HEAD"; + path: string; + status: number; + headers: Record; + message: T; + } +} + +const NOT_YET = {} as any; diff --git a/src/http/HttpLlmFunctionFetcher.ts b/src/http/HttpLlmFunctionFetcher.ts new file mode 100644 index 0000000..100b37e --- /dev/null +++ b/src/http/HttpLlmFunctionFetcher.ts @@ -0,0 +1,57 @@ +import type { HttpLlm } from "../HttpLlm"; +import type { HttpMigration } from "../HttpMigration"; +import { IHttpMigrateRoute } from "../structures/IHttpMigrateRoute"; +import { IHttpResponse } from "../structures/IHttpResponse"; +import { HttpMigrateRouteFetcher } from "./HttpMigrateRouteFetcher"; + +export namespace HttpLlmFunctionFetcher { + export const execute = async (props: HttpLlm.IFetchProps): Promise => + HttpMigrateRouteFetcher.execute(getFetchArguments("execute", props)); + + export const propagate = async ( + props: HttpLlm.IFetchProps, + ): Promise => + HttpMigrateRouteFetcher.propagate(getFetchArguments("propagate", props)); + + const getFetchArguments = ( + from: string, + props: HttpLlm.IFetchProps, + ): HttpMigration.IFetchProps => { + const route: IHttpMigrateRoute = props.function.route(); + if (props.application.options.keyword === true) { + const input: Record = props.arguments[0]; + const valid: boolean = + props.arguments.length === 1 && + typeof input === "object" && + input !== null; + if (valid === false) + throw new Error( + `Error on HttpLlmFunctionFetcher.${from}(): keyworded arguments must be an object`, + ); + return { + connection: props.connection, + route, + parameters: Object.fromEntries( + route.parameters.map((p) => [p.key, input[p.key]] as const), + ), + query: input.query, + body: input.body, + }; + } + const parameters: Array = + props.arguments.slice(0, route.parameters.length); + const query: object | undefined = route.query + ? props.arguments[route.parameters.length] + : undefined; + const body: object | undefined = route.body + ? props.arguments[route.parameters.length + (route.query ? 1 : 0)] + : undefined; + return { + connection: props.connection, + route, + parameters, + query, + body, + }; + }; +} diff --git a/src/http/HttpMigrateRouteFetcher.ts b/src/http/HttpMigrateRouteFetcher.ts new file mode 100644 index 0000000..71bd69f --- /dev/null +++ b/src/http/HttpMigrateRouteFetcher.ts @@ -0,0 +1,204 @@ +import type { HttpMigration } from "../HttpMigration"; +import { IHttpConnection } from "../structures/IHttpConnection"; +import { IHttpResponse } from "../structures/IHttpResponse"; +import { HttpError } from "./HttpError"; + +export namespace HttpMigrateRouteFetcher { + export const execute = async ( + props: HttpMigration.IFetchProps, + ): Promise => { + const result: IHttpResponse = await _Propagate("request", props); + props.route.success?.media; + if (result.status !== 200 && result.status !== 201) + throw new HttpError( + props.route.method.toUpperCase() as "GET", + props.route.path, + result.status, + result.headers, + result.body as string, + ); + return result.body; + }; + + export const propagate = ( + props: HttpMigration.IFetchProps, + ): Promise => _Propagate("propagate", props); +} + +const _Propagate = async ( + from: string, + props: HttpMigration.IFetchProps, +): Promise => { + // VALIDATE PARAMETERS + const error = (message: string) => + new Error(`Error on MigrateRouteFetcher.${from}(): ${message}`); + if (Array.isArray(props.parameters)) { + if (props.route.parameters.length !== props.parameters.length) + throw error(`number of parameters is not matched.`); + } else if ( + props.route.parameters.every( + (p) => (props.parameters as Record)[p.key] !== undefined, + ) === false + ) + throw error(`number of parameters is not matched.`); + + // VALIDATE QUERY + if (!!props.route.query !== !!props.query) + throw error(`query is not matched.`); + else if (!!props.route.body !== (props.body !== undefined)) + throw error(`body is not matched.`); + + // INIT REQUEST DATA + const headers: Record = { + ...(props.connection.headers ?? {}), + ...(props.route.body?.type && + props.route.body.type !== "multipart/form-data" + ? { "Content-Type": props.route.body.type } + : {}), + }; + const init: RequestInit = { + ...(props.connection.options ?? {}), + method: props.route.method, + headers: (() => { + const output: [string, string][] = []; + for (const [key, value] of Object.entries(headers)) + if (value === undefined) continue; + else if (Array.isArray(value)) + for (const v of value) output.push([key, String(v)]); + else output.push([key, String(value)]); + return output; + })(), + }; + if (props.body !== undefined) + init.body = ( + props.route.body?.type === "application/x-www-form-urlencoded" + ? requestQueryBody(props.body) + : props.route.body?.type === "multipart/form-data" + ? requestFormDataBody(props.body) + : props.route.body?.type === "application/json" + ? JSON.stringify(props.body) + : props.body + ) as any; + + // DO REQUEST + const path: string = + props.connection.host[props.connection.host.length - 1] !== "/" && + props.route.path[0] !== "/" + ? `/${getPath(props)}` + : getPath(props); + const url: URL = new URL(`${props.connection.host}/${path}`); + + const response: Response = await (props.connection.fetch ?? fetch)(url, init); + const status: number = response.status; + const out = (body: unknown): IHttpResponse => ({ + status, + headers: responseHeaders(response.headers), + body, + }); + + if (status === 200 || status === 201) { + // SUCCESS CASE + if (props.route.method === "head") return out(undefined); + else if ( + props.route.success === null || + props.route.success.type === "text/plain" + ) + return out(await response.text()); + else if (props.route.success.type === "application/json") { + const text: string = await response.text(); + return out(text.length ? JSON.parse(text) : undefined); + } else if (props.route.success.type === "application/x-www-form-urlencoded") + return out(new URLSearchParams(await response.text())); + else if (props.route.success.type === "multipart/form-data") + return out(await response.formData()); + throw error("Unsupported response body type."); + } else { + // FAILURE CASE + const type: string = ( + response.headers.get("content-type") ?? + response.headers.get("Content-Type") ?? + "" + ) + .split(";")[0] + .trim(); + if (type === "" || type.startsWith("text/")) + return out(await response.text()); + else if (type === "application/json") return out(await response.json()); + else if (type === "application/x-www-form-urlencoded") + return out(new URLSearchParams(await response.text())); + else if (type === "multipart/form-data") + return out(await response.formData()); + else if (type === "application/octet-stream") + return out(await response.blob()); + return out(await response.text()); + } +}; + +const getPath = ( + props: Pick, +): string => { + let path: string = props.route.emendedPath; + props.route.parameters.forEach((p, i) => { + path = path.replace( + `:${p.key}`, + encodeURIComponent( + String( + (Array.isArray(props.parameters) + ? props.parameters[i] + : props.parameters[p.key]) ?? "null", + ), + ), + ); + }); + if (props.route.query) path += getQueryPath(props.query ?? {}); + return path; +}; + +const getQueryPath = (query: Record): string => { + const variables = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) + if (undefined === value) continue; + else if (Array.isArray(value)) + value.forEach((elem: any) => variables.append(key, String(elem))); + else variables.set(key, String(value)); + return 0 === variables.size ? "" : `?${variables.toString()}`; +}; + +const requestQueryBody = (input: any): URLSearchParams => { + const q: URLSearchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(input)) + if (value === undefined) continue; + else if (Array.isArray(value)) + value.forEach((elem) => q.append(key, String(elem))); + else q.set(key, String(value)); + return q; +}; +const requestFormDataBody = (input: Record): FormData => { + const encoded: FormData = new FormData(); + const append = (key: string) => (value: any) => { + if (value === undefined) return; + else if (value instanceof Blob) + if (value instanceof File) encoded.append(key, value, value.name); + else encoded.append(key, value); + else encoded.append(key, String(value)); + }; + for (const [key, value] of Object.entries(input)) + if (Array.isArray(value)) value.map(append(key)); + else append(key)(value); + return encoded; +}; + +const responseHeaders = ( + headers: Headers, +): Record => { + const output: Record = {}; + headers.forEach((value, key) => { + if (key === "set-cookie") { + output[key] ??= []; + (output[key] as string[]).push( + ...value.split(";").map((str) => str.trim()), + ); + } else output[key] = value; + }); + return output; +}; diff --git a/src/index.ts b/src/index.ts index 1681d00..a2d2ec3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,22 @@ -export * from "./IMigrateRoute"; -export * from "./IMigrateDocument"; -export * from "./OpenApi"; -export * from "./OpenApiTypeChecker"; +// STRUCTURES +export * from "./structures/IHttpConnection"; +export * from "./structures/IHttpLlmApplication"; +export * from "./structures/IHttpLlmFunction"; +export * from "./structures/IHttpMigrateRoute"; +export * from "./structures/IHttpMigrateApplication"; +export * from "./structures/IHttpResponse"; +export * from "./structures/ILlmApplication"; +export * from "./structures/ILlmSchema"; + +// UTILS +export * from "./http/HttpError"; +export * from "./utils/OpenApiTypeChecker"; +export * from "./utils/LlmTypeChecker"; +// OPENAPI MODULES +export * from "./OpenApi"; export * from "./SwaggerV2"; export * from "./OpenApiV3"; export * from "./OpenApiV3_1"; +export * from "./HttpLlm"; +export * from "./HttpMigration"; diff --git a/src/internal/OpenApiTypeChecker.ts b/src/internal/OpenApiTypeChecker.ts deleted file mode 100644 index 64f9a56..0000000 --- a/src/internal/OpenApiTypeChecker.ts +++ /dev/null @@ -1,2 +0,0 @@ -// FOR LEGACY VERSIONS -export * from "../OpenApiTypeChecker"; diff --git a/src/structures/IHttpConnection.ts b/src/structures/IHttpConnection.ts new file mode 100644 index 0000000..58649dc --- /dev/null +++ b/src/structures/IHttpConnection.ts @@ -0,0 +1,216 @@ +/// + +/** + * Connection information. + * + * `IConnection` is an interface ttype who represents connection information of the + * remote HTTP server. You can target the remote HTTP server by wring the + * {@link IHttpConnection.host} variable down. Also, you can configure special header values + * by specializing the {@link IHttpConnection.headers} variable. + * + * If the remote HTTP server encrypts or decrypts its body data through the AES-128/256 + * algorithm, specify the {@link IHttpConnection.encryption} with {@link IEncryptionPassword} + * or {@link IEncryptionPassword.Closure} variable. + * + * @author Jenogho Nam - https://github.com/samchon + * @author Seungjun We - https://github.com/SeungjunWe + */ +export interface IHttpConnection< + Headers extends object | undefined = object | undefined, +> { + /** + * Host address of the remote HTTP server. + */ + host: string; + + /** + * Header values delivered to the remote HTTP server. + */ + headers?: Record & + IHttpConnection.Headerify; + + /** + * Additional options for the `fetch` function. + */ + options?: IHttpConnection.IOptions; + + /** + * Custom fetch function. + * + * If you want to use custom `fetch` function instead of built-in, + * assign your custom `fetch` function into this property. + * + * For reference, the `fetch` function has started to be supported + * since version 20 of NodeJS. Therefore, if you are using NodeJS + * version 19 or lower, you have to assign the `node-fetch` module + * into this property. + */ + fetch?: typeof fetch; +} +export namespace IHttpConnection { + /** + * Addiotional options for the `fetch` function. + * + * Almost same with {@link RequestInit} type of the {@link fetch} function, + * but `body`, `headers` and `method` properties are omitted. + * + * The reason why defining duplicated definition of {@link RequestInit} + * is for legacy NodeJS environments, which does not have the {@link fetch} + * function type. + */ + export interface IOptions { + /** + * A string indicating how the request will interact with the browser's + * cache to set request's cache. + */ + cache?: + | "default" + | "force-cache" + | "no-cache" + | "no-store" + | "only-if-cached" + | "reload"; + + /** + * A string indicating whether credentials will be sent with the request + * always, never, or only when sent to a same-origin URL. Sets request's + * credentials. + */ + credentials?: "omit" | "same-origin" | "include"; + + /** + * A cryptographic hash of the resource to be fetched by request. + * + * Sets request's integrity. + */ + integrity?: string; + + /** + * A boolean to set request's keepalive. + */ + keepalive?: boolean; + + /** + * A string to indicate whether the request will use CORS, or will be + * restricted to same-origin URLs. + * + * Sets request's mode. + */ + mode?: "cors" | "navigate" | "no-cors" | "same-origin"; + + /** + * A string indicating whether request follows redirects, results in + * an error upon encountering a redirect, or returns the redirect + * (in an opaque fashion). + * + * Sets request's redirect. + */ + redirect?: "error" | "follow" | "manual"; + + /** + * A string whose value is a same-origin URL, "about:client", or the + * empty string, to set request's referrer. + */ + referrer?: string; + + /** + * A referrer policy to set request's referrerPolicy. + */ + referrerPolicy?: + | "" + | "no-referrer" + | "no-referrer-when-downgrade" + | "origin" + | "origin-when-cross-origin" + | "same-origin" + | "strict-origin" + | "strict-origin-when-cross-origin" + | "unsafe-url"; + + /** + * An AbortSignal to set request's signal. + */ + signal?: AbortSignal | null; + } + + /** + * Type of allowed header values. + * + * Only atomic or array of atomic values are allowed. + */ + export type HeaderValue = + | string + | boolean + | number + | bigint + | string + | Array + | Array + | Array + | Array + | Array; + + /** + * Type of headers + * + * `Headerify` removes every properties that are not allowed in the + * HTTP headers type. + * + * Below are list of prohibited in HTTP headers. + * + * 1. Value type one of {@link HeaderValue} + * 2. Key is "set-cookie", but value is not an Array type + * 3. Key is one of them, but value is Array type + * - "age" + * - "authorization" + * - "content-length" + * - "content-type" + * - "etag" + * - "expires" + * - "from" + * - "host" + * - "if-modified-since" + * - "if-unmodified-since" + * - "last-modified" + * - "location" + * - "max-forwards" + * - "proxy-authorization" + * - "referer" + * - "retry-after" + * - "server" + * - "user-agent" + */ + export type Headerify = { + [P in keyof T]?: T[P] extends HeaderValue | undefined + ? P extends string + ? Lowercase

extends "set-cookie" + ? T[P] extends Array + ? T[P] | undefined + : never + : Lowercase

extends + | "age" + | "authorization" + | "content-length" + | "content-type" + | "etag" + | "expires" + | "from" + | "host" + | "if-modified-since" + | "if-unmodified-since" + | "last-modified" + | "location" + | "max-forwards" + | "proxy-authorization" + | "referer" + | "retry-after" + | "server" + | "user-agent" + ? T[P] extends Array + ? never + : T[P] | undefined + : T[P] | undefined + : never + : never; + }; +} diff --git a/src/structures/IHttpLlmApplication.ts b/src/structures/IHttpLlmApplication.ts new file mode 100644 index 0000000..98ba33d --- /dev/null +++ b/src/structures/IHttpLlmApplication.ts @@ -0,0 +1,210 @@ +import { OpenApi } from "../OpenApi"; +import { IHttpLlmFunction } from "./IHttpLlmFunction"; +import { IHttpMigrateRoute } from "./IHttpMigrateRoute"; +import { ILlmSchema } from "./ILlmSchema"; + +/** + * Application of LLM function call from OpenAPI document. + * + * `IHttpLlmApplication` is a data structure representing collection of + * {@link IHttpLlmFunction LLM function calling schemas} composed from the + * {@link OpenApi.IDocument OpenAPI document} and its {@link OpenApi.IOperation operation} + * metadata. It also contains {@link IHttpLlmApplication.errors failed operations}, and + * adjusted {@link IHttpLlmApplication.options options} during the `IHttpLlmApplication` + * construction. + * + * About the {@link OpenApi.IOperation API operations}, they are converted to + * {@link IHttpLlmFunction} type which represents LLM function calling schema. + * By the way, if tehre're some recursive types which can't escape the + * {@link OpenApi.IJsonSchema.IReference} type, the operation would be failed and + * pushed into the {@link IHttpLlmApplication.errors}. Otherwise not, the operation + * would be successfully converted to {@link IHttpLlmFunction} and its type schemas + * are downgraded to {@link OpenApiV3.IJsonSchema} and converted to {@link ILlmSchema}. + * + * About the options, if you've configured {@link IHttpLlmApplication.options.keyword} + * (as `true`), number of {@link IHttpLlmFunction.parameters} are always 1 and the first + * parameter type is always {@link ILlmSchema.IObject}. Otherwise, the parameters would + * be multiple, and the sequence of the parameters are following below rules. + * + * - `pathParameters`: Path parameters of {@link IHttpMigrateRoute.parameters} + * - `query`: Query parameter of {@link IHttpMigrateRoute.query} + * - `body`: Body parameter of {@link IHttpMigrateRoute.body} + * + * ```typescript + * // KEYWORD TRUE + * { + * ...pathParameters, + * query, + * body, + * } + * + * // KEYWORD FALSE + * [ + * ...pathParameters, + * ...(query ? [query] : []), + * ...(body ? [body] : []), + * ] + * ``` + * + * By the way, there can be some parameters (or their nested properties) which must be + * composed by human, not by LLM. File uploading feature or some sensitive information + * like secrety key (password) are the examples. In that case, you can separate the + * function parameters to both LLM and human sides by configuring the + * {@link IHttpLlmApplication.IOptions.separate} property. The separated parameters are + * assigned to the {@link IHttpLlmFunction.separated} property. + * + * For reference, the actual function call execution is not by LLM, but by you. + * When the LLM selects the proper function and fills the arguments, you just call + * the function by {@link HttpLlm.execute} with the LLM prepared arguments. And then + * informs the return value to the LLM by system prompt. The LLM will continue the next + * conversation based on the return value. + * + * Additionally, if you've configured {@link IHttpLlmApplication.IOptions.separate}, + * so that the parameters are separated to human and LLM sides, you can merge these + * humand and LLM sides' parameters into one through {@link HttpLlm.mergeParameters} + * before the actual LLM function call execution. + * + * @author Jeongho Nam - https://github.com/samchon + */ +export interface IHttpLlmApplication< + Schema extends ILlmSchema = ILlmSchema, + Operation extends OpenApi.IOperation = OpenApi.IOperation, + Route extends IHttpMigrateRoute = IHttpMigrateRoute, +> { + /** + * Version of OpenAPI. + * + * LLM function call schema is based on OpenAPI 3.0.3 specification. + */ + openapi: "3.0.3"; + + /** + * List of function metadata. + * + * List of function metadata that can be used for the LLM function call. + * + * When you want to execute the function with LLM constructed arguments, + * you can do it through {@link LlmFetcher.execute} function. + */ + functions: IHttpLlmFunction[]; + + /** + * List of errors occurred during the composition. + */ + errors: IHttpLlmApplication.IError[]; + + /** + * Options for the document. + * + * Adjusted options when composing the document through + * {@link HttpLlm.application} function. + */ + options: IHttpLlmApplication.IOptions; +} +export namespace IHttpLlmApplication { + /** + * Error occurred in the composition. + */ + export interface IError< + Operation extends OpenApi.IOperation = OpenApi.IOperation, + Route extends IHttpMigrateRoute = IHttpMigrateRoute, + > { + /** + * HTTP method of the endpoint. + */ + method: "get" | "post" | "put" | "patch" | "delete" | "head"; + + /** + * Path of the endpoint. + */ + path: string; + + /** + * Error messsages. + */ + messages: string[]; + + /** + * Get the Swagger operation metadata. + * + * Get the Swagger operation metadata, of the source. + */ + operation: () => Operation; + + /** + * Get the migration route metadata. + * + * Get the migration route metadata, of the source. + * + * If the property returns `undefined`, it means that the error has + * been occured in the migration level, not of LLM document composition. + * + * @returns Migration route metadata. + */ + route: () => Route | undefined; + } + + /** + * Options for composing the LLM document. + */ + export interface IOptions { + /** + * Whether the parameters are keyworded or not. + * + * If this property value is `true`, length of the + * {@link IHttpLlmApplication.IFunction.parameters} is always 1, and type of + * the pararameter is always {@link ILlmSchema.IObject} type. + * + * Otherwise, the parameters would be multiple, and the sequence of the parameters + * are following below rules. + * + * ```typescript + * // KEYWORD TRUE + * { + * ...pathParameters, + * query, + * body, + * } + * + * // KEYWORD FALSE + * [ + * ...pathParameters, + * ...(query ? [query] : []), + * ...(body ? [body] : []), + * ] + * ``` + * + * @default false + */ + keyword: boolean; + + /** + * Separator function for the parameters. + * + * When composing parameter arguments through LLM function call, + * there can be a case that some parameters must be composed by human, + * or LLM cannot understand the parameter. For example, if the + * parameter type has configured + * {@link ILlmSchema.IString.contentMediaType} which indicates file + * uploading, it must be composed by human, not by LLM + * (Large Language Model). + * + * In that case, if you configure this property with a function that + * predicating whether the schema value must be composed by human or + * not, the parameters would be separated into two parts. + * + * - {@link IHttpLlmFunction.separated.llm} + * - {@link IHttpLlmFunction.separated.human} + * + * When writing the function, note that returning value `true` means + * to be a human composing the value, and `false` means to LLM + * composing the value. Also, when predicating the schema, it would + * better to utilize the {@link LlmTypeChecker} features. + * + * @param schema Schema to be separated. + * @returns Whether the schema value must be composed by human or not. + * @default null + */ + separate: null | ((schema: Schema) => boolean); + } +} diff --git a/src/structures/IHttpLlmFunction.ts b/src/structures/IHttpLlmFunction.ts new file mode 100644 index 0000000..a36e99d --- /dev/null +++ b/src/structures/IHttpLlmFunction.ts @@ -0,0 +1,239 @@ +import { OpenApi } from "../OpenApi"; +import { IHttpMigrateRoute } from "./IHttpMigrateRoute"; +import { ILlmSchema } from "./ILlmSchema"; + +/** + * LLM function calling schema from HTTP (OpenAPI) operation. + * + * `IHttpLlmFunction` is a data structure representing a function converted + * from the {@link OpenApi.IOperation OpenAPI operation}, used for the LLM + * (Large Language Model) function calling. It's a typical RPC (Remote Procedure Call) + * structure containing the function {@link name}, {@link parameters}, and + * {@link output return type}. + * + * If you provide this `IHttpLlmFunction` data to the LLM provider like "OpenAI", + * the "OpenAI" will compose a function arguments by analyzing conversations with + * the user. With the LLM composed arguments, you can execute the function through + * {@link LlmFetcher.execute} and get the result. + * + * For reference, different between `IHttpLlmFunction` and its origin source + * {@link OpenApi.IOperation} is, `IHttpLlmFunction` has converted every type schema + * informations from {@link OpenApi.IJsonSchema} to {@link ILlmSchema} to escape + * {@link OpenApi.IJsonSchema.IReference reference types}, and downgrade the version + * of the JSON schema to OpenAPI 3.0. It's because LLM function call feature cannot + * understand both reference types and OpenAPI 3.1 specification. + * + * Additionally, if you've composed `IHttpLlmFunction` with + * {@link IHttpLlmApplication.IOptions.keyword} configuration as `true`, number of + * {@link IHttpLlmFunction.parameters} are always 1 and the first parameter's + * type is always {@link ILlmSchema.IObject}. The properties' rule is: + * + * - `pathParameters`: Path parameters of {@link OpenApi.IOperation.parameters} + * - `query`: Query parameter of {@link IHttpMigrateRoute.query} + * - `body`: Body parameter of {@link IHttpMigrateRoute.body} + * + * ```typescript + * { + * ...pathParameters, + * query, + * body, + * } + * ``` + * + * Otherwise, the parameters would be multiple, and the sequence of the parameters + * are following below rules: + * + * ```typescript + * [ + * ...pathParameters, + * ...(query ? [query] : []), + * ...(body ? [body] : []), + * ] + * ``` + * + * @reference https://platform.openai.com/docs/guides/function-calling + * @author Jeongho Nam - https://github.com/samchon + */ +export interface IHttpLlmFunction< + Schema extends ILlmSchema = ILlmSchema, + Operation extends OpenApi.IOperation = OpenApi.IOperation, + Route extends IHttpMigrateRoute = IHttpMigrateRoute, +> { + /** + * HTTP method of the endpoint. + */ + method: "get" | "post" | "patch" | "put" | "delete"; + + /** + * Path of the endpoint. + */ + path: string; + + /** + * Representative name of the function. + * + * The `name` is a repsentative name identifying the function in the + * {@link IHttpLlmApplication}. The `name` value is just composed by joining the + * {@link IHttpMigrateRoute.accessor} by underscore `_` character. + * + * Here is the composition rule of the {@link IHttpMigrateRoute.accessor}: + * + * > The `accessor` is composed with the following rules. At first, + * > namespaces are composed by static directory names in the {@link path}. + * > Parametric symbols represented by `:param` or `{param}` cannot be + * > a part of the namespace. + * > + * > Instead, they would be a part of the function name. The function + * > name is composed with the {@link method HTTP method} and parametric + * > symbols like `getByParam` or `postByParam`. If there are multiple + * > path parameters, they would be concatenated by `And` like + * > `getByParam1AndParam2`. + * > + * > For refefence, if the {@link operation}'s {@link method} is `delete`, + * > the function name would be replaced to `erase` instead of `delete`. + * > It is the reason why the `delete` is a reserved keyword in many + * > programming languages. + * > + * > - Example 1 + * > - path: `POST /shopping/sellers/sales` + * > - accessor: `shopping.sellers.sales.post` + * > - Example 2 + * > - endpoint: `GET /shoppings/sellers/sales/:saleId/reviews/:reviewId/comments/:id + * > - accessor: `shoppings.sellers.sales.reviews.getBySaleIdAndReviewIdAndCommentId` + */ + name: string; + + /** + * Whether the function schema types are strict or not. + * + * Newly added specification to "OpenAI" at 2024-08-07. + * + * @reference https://openai.com/index/introducing-structured-outputs-in-the-api/ + */ + strict: true; + + /** + * List of parameter types. + * + * If you've configured {@link IHttpLlmApplication.IOptions.keyword} as `true`, + * number of {@link IHttpLlmFunction.parameters} are always 1 and the first + * parameter's type is always {@link ILlmSchema.IObject}. The + * properties' rule is: + * + * - `pathParameters`: Path parameters of {@link IHttpMigrateRoute.parameters} + * - `query`: Query parameter of {@link IHttpMigrateRoute.query} + * - `body`: Body parameter of {@link IHttpMigrateRoute.body} + * + * ```typescript + * { + * ...pathParameters, + * query, + * body, + * } + * ``` + * + * Otherwise, the parameters would be multiple, and the sequence of the + * parameters are following below rules: + * + * ```typescript + * [ + * ...pathParameters, + * ...(query ? [query] : []), + * ...(body ? [body] : []), + * ] + * ``` + */ + parameters: Schema[]; + + /** + * Collection of separated parameters. + * + * Filled only when {@link IHttpLlmApplication.IOptions.separate} is configured. + */ + separated?: IHttpLlmFunction.ISeparated; + + /** + * Expected return type. + * + * If the target operation returns nothing (`void`), the `output` + * would be `undefined`. + */ + output?: Schema | undefined; + + /** + * Description of the function. + * + * `IHttpLlmFunction.description` is composed by below rule: + * + * 1. Starts from the {@link OpenApi.IOperation.summary} paragraph. + * 2. The next paragraphs are filled with the + * {@link OpenApi.IOperation.description}. By the way, if the first + * paragraph of {@link OpenApi.IOperation.description} is same with the + * {@link OpenApi.IOperation.summary}, it would not be duplicated. + * 3. Parameters' descriptions are added with `@param` tag. + * 4. {@link OpenApi.IOperation.security Security requirements} are added + * with `@security` tag. + * 5. Tag names are added with `@tag` tag. + * 6. If {@link OpenApi.IOperation.deprecated}, `@deprecated` tag is added. + * + * For reference, the `description` is very important property to teach + * the purpose of the function to the LLM (Language Large Model), and + * LLM actually determines which function to call by the description. + * + * Also, when the LLM conversates with the user, the `description` is + * used to explain the function to the user. Therefore, the `description` + * property has the highest priroity, and you have to consider it. + */ + description?: string; + + /** + * Get the Swagger operation metadata. + * + * Get the Swagger operation metadata, of the source. + * + * @returns Swagger operation metadata. + */ + operation: () => Operation; + + /** + * Get the migration route metadata. + * + * Get the migration route metadata, of the source. + * + * @returns Migration route metadata. + */ + route: () => Route; +} +export namespace IHttpLlmFunction { + /** + * Collection of separated parameters. + */ + export interface ISeparated { + /** + * Parameters that would be composed by the LLM. + */ + llm: ISeparatedParameter[]; + + /** + * Parameters that would be composed by the human. + */ + human: ISeparatedParameter[]; + } + + /** + * Separated parameter. + */ + export interface ISeparatedParameter { + /** + * Index of the parameter. + * + * @type uint + */ + index: number; + + /** + * Type schema info of the parameter. + */ + schema: Schema; + } +} diff --git a/src/IMigrateDocument.ts b/src/structures/IHttpMigrateApplication.ts similarity index 62% rename from src/IMigrateDocument.ts rename to src/structures/IHttpMigrateApplication.ts index 89d7230..737d1ed 100644 --- a/src/IMigrateDocument.ts +++ b/src/structures/IHttpMigrateApplication.ts @@ -1,34 +1,40 @@ -import { IMigrateRoute } from "./IMigrateRoute"; -import { OpenApi } from "./OpenApi"; +import { OpenApi } from "../OpenApi"; +import { IHttpMigrateRoute } from "./IHttpMigrateRoute"; /** * Document of migration. * - * The `IMigrateDocument` interface is a document of migration from - * {@link OpenAPI.IDocument OpenAPI document} to RPC (Remote Procedure Call) - * functions; {@link IMigrateRoute}. + * The `IHttpMigrateApplication` interface is an application migrated from + * {@link OpenAPI.IDocument OpenAPI document} for supporting the OpenAPI generator + * libraries which compose RPC (Remote Procedure Call) functions from the + * {@link OpenAPI.IOperation OpenAPI operations}. * - * As the `IMigrateDocument` and {@link IMigrateRoute} have a lot of special + * As the `IHttpMigrateApplication` and {@link IHttpMigrateRoute} have a lot of special * stories, when you're developing OpenAPI generator library, please read * their descriptions carefully including the description of properties. * * @author Jeongho Nam - https://github.com/samchon */ -export interface IMigrateDocument< +export interface IHttpMigrateApplication< Schema extends OpenApi.IJsonSchema = OpenApi.IJsonSchema, Operation extends OpenApi.IOperation = OpenApi.IOperation, > { /** * List of routes for migration. */ - routes: IMigrateRoute[]; + routes: IHttpMigrateRoute[]; /** * List of errors occurred during the migration. */ - errors: IMigrateDocument.IError[]; + errors: IHttpMigrateApplication.IError[]; + + /** + * Source OpenAPI document. + */ + document: () => OpenApi.IDocument; } -export namespace IMigrateDocument { +export namespace IHttpMigrateApplication { /** * Error of migration in the operation level. */ diff --git a/src/IMigrateRoute.ts b/src/structures/IHttpMigrateRoute.ts similarity index 87% rename from src/IMigrateRoute.ts rename to src/structures/IHttpMigrateRoute.ts index bd297d7..5341273 100644 --- a/src/IMigrateRoute.ts +++ b/src/structures/IHttpMigrateRoute.ts @@ -1,19 +1,19 @@ -import { OpenApi } from "./OpenApi"; +import { OpenApi } from "../OpenApi"; /** * Route information for migration. * - * The `IMigrateRoute` is a structure representing a route information for - * OpenAPI generated RPC (Remote Procedure Call) function composed from the - * {@link OpenApi.IOperation OpenAPI operation}. + * The `IHttpMigrateRoute` is a structure representing a route information for + * OpenAPI generator libraries, which composes an RPC (Remote Procedure Call) function + * from the {@link OpenApi.IOperation OpenAPI operation}. * - * As the `IMigrateRoute` has a lot of speical stories, when you're developing + * As the `IHttpMigrateRoute` has a lot of speical stories, when you're developing * OpenAPI generator library, please read its description carefully including * the description of its properties. * * @author Jeongho Nam - https://github.com/samchon */ -export interface IMigrateRoute< +export interface IHttpMigrateRoute< Schema extends OpenApi.IJsonSchema = OpenApi.IJsonSchema, Operation extends OpenApi.IOperation = OpenApi.IOperation, > { @@ -78,7 +78,7 @@ export interface IMigrateRoute< * * Note that, not a list of every parameters, but only path parameters. */ - parameters: IMigrateRoute.IParameter[]; + parameters: IHttpMigrateRoute.IParameter[]; /** * Metadata of headers. @@ -86,16 +86,16 @@ export interface IMigrateRoute< * The `headers` property is a metadata of HTTP request headers for RPC function, * including the parameter variable name and schema. * - * Also, its {@link IMigrateRoute.IHeaders.schema} is always object or reference + * Also, its {@link IHttpMigrateRoute.IHeaders.schema} is always object or reference * to object. Even though the original {@link OpenApi.IOperation OpenAPI operation}'s * headers are separated to atomic typed properties, the `headers` property forcibly * combines them into a single object type. * * For reference, if the `headers` property has been converted to an object type - * forcibly, its property {@link IMigrateRoute.IHeaders.name name} and - * {@link IMigrateRoute.IHeaders.key key} are always "headers". + * forcibly, its property {@link IHttpMigrateRoute.IHeaders.name name} and + * {@link IHttpMigrateRoute.IHeaders.key key} are always "headers". */ - headers: IMigrateRoute.IHeaders | null; + headers: IHttpMigrateRoute.IHeaders | null; /** * Metadata of query values. @@ -103,16 +103,16 @@ export interface IMigrateRoute< * The `query` property is a metadata of HTTP request query values for RPC function, * including the parameter variable name and schema. * - * Also, its {@link IMigrateRoute.IQuery.schema} is always object or reference + * Also, its {@link IHttpMigrateRoute.IQuery.schema} is always object or reference * to object. Even though the original {@link OpenApi.IOperation OpenAPI operation}'s * query parameters are separated to atomic typed properties, the `query` property * forcibly combines them into a single object type. * * For reference, if the `query` property has been converted to an object type - * forcibly, its property {@link IMigrateRoute.IQuery.name name} and - * {@link IMigrateRoute.IQuery.key key} are always "headers". + * forcibly, its property {@link IHttpMigrateRoute.IQuery.name name} and + * {@link IHttpMigrateRoute.IQuery.key key} are always "headers". */ - query: IMigrateRoute.IQuery | null; + query: IHttpMigrateRoute.IQuery | null; /** * Metadata of request body. @@ -123,7 +123,7 @@ export interface IMigrateRoute< * If the `body` property is `null`, it means the operation does not require * the request body data. */ - body: IMigrateRoute.IBody | null; + body: IHttpMigrateRoute.IBody | null; /** * Metadata of response body for success case. @@ -134,7 +134,7 @@ export interface IMigrateRoute< * If the `success` property is `null`, it means the operation does not have * the response body data. In other words, the RPC function would return `void`. */ - success: IMigrateRoute.IBody | null; + success: IHttpMigrateRoute.IBody | null; /** * Metadata of response body for exceptional status cases. @@ -147,7 +147,7 @@ export interface IMigrateRoute< * stringified number, but sometimes it could be a string like "default", * because the OpenAPI document allows the status code to be a string. */ - exceptions: Record>; + exceptions: Record>; /** * Description comment for the route function. @@ -173,7 +173,7 @@ export interface IMigrateRoute< */ operation: () => Operation; } -export namespace IMigrateRoute { +export namespace IHttpMigrateRoute { /** * Metadata of path parameter. */ diff --git a/src/structures/IHttpResponse.ts b/src/structures/IHttpResponse.ts new file mode 100644 index 0000000..47684eb --- /dev/null +++ b/src/structures/IHttpResponse.ts @@ -0,0 +1,25 @@ +/** + * Represents an HTTP response. + * + * The `IHttpResponse` interface represents an HTTP response. + * + * It contains the {@link status} code, {@link headers}, and {@link body} of the response. + * + * @author Jeongho Nam - https://github.com/samchon + */ +export interface IHttpResponse { + /** + * Status code of the response. + */ + status: number; + + /** + * Headers of the response. + */ + headers: Record; + + /** + * Body of the response. + */ + body: unknown; +} diff --git a/src/structures/ILlmApplication.ts b/src/structures/ILlmApplication.ts new file mode 100644 index 0000000..014b8c7 --- /dev/null +++ b/src/structures/ILlmApplication.ts @@ -0,0 +1,48 @@ +import { ILlmFunction } from "./ILlmFunction"; +import { ILlmSchema } from "./ILlmSchema"; + +export interface ILlmApplication { + /** + * List of function metadata. + * + * List of function metadata that can be used for the LLM function call. + */ + functions: ILlmFunction[]; + + /** + * Options for the document. + */ + options: ILlmApplication.IOptions; +} +export namespace ILlmApplication { + export interface IOptions { + /** + * Separator function for the parameters. + * + * When composing parameter arguments through LLM function call, + * there can be a case that some parameters must be composed by human, + * or LLM cannot understand the parameter. For example, if the + * parameter type has configured + * {@link ILlmSchema.IString.contentMediaType} which indicates file + * uploading, it must be composed by human, not by LLM + * (Large Language Model). + * + * In that case, if you configure this property with a function that + * predicating whether the schema value must be composed by human or + * not, the parameters would be separated into two parts. + * + * - {@link ILlmFunction.separated.llm} + * - {@link ILlmFunction.separated.human} + * + * When writing the function, note that returning value `true` means + * to be a human composing the value, and `false` means to LLM + * composing the value. Also, when predicating the schema, it would + * better to utilize the {@link LlmTypeChecker} features. + * + * @param schema Schema to be separated. + * @returns Whether the schema value must be composed by human or not. + * @default null + */ + separate: null | ((schema: Schema) => boolean); + } +} diff --git a/src/structures/ILlmFunction.ts b/src/structures/ILlmFunction.ts new file mode 100644 index 0000000..e0cef71 --- /dev/null +++ b/src/structures/ILlmFunction.ts @@ -0,0 +1,94 @@ +import { ILlmSchema } from "./ILlmSchema"; + +/** + * LLM function metadata. + * + * `ILlmFunction` is an interface representing a function metadata, + * which has been used for the LLM (Language Large Model) function + * calling. Also, it's a function structure containing the function + * {@link name}, {@link parameters} and {@link output return type}. + * + * If you provide this `ILlmFunction` data to the LLM provider like "OpenAI", + * the "OpenAI" will compose a function arguments by analyzing conversations + * with the user. With the LLM composed arguments, you can execute the function + * and get the result. + * + * By the way, do not ensure that LLM will always provide the correct + * arguments. The LLM of present age is not perfect, so that you would + * better to validate the arguments before executing the function. + * I recommend you to validate the arguments before execution by using + * [`typia`](https://github.com/samchon/typia) library. + * + * @reference https://platform.openai.com/docs/guides/function-calling + * @author Jeongho Nam - https://github.com/samchon + */ +export interface ILlmFunction { + /** + * Representative name of the function. + */ + name: string; + + /** + * List of parameter types. + */ + parameters: Schema[]; + + /** + * Collection of separated parameters. + */ + separated?: ILlmFunction.ISeparated; + + /** + * Expected return type. + * + * If the function returns nothing (`void`), the `output` value would + * be `undefined`. + */ + output?: Schema | undefined; + + /** + * Description of the function. + * + * For reference, the `description` is very important property to teach + * the purpose of the function to the LLM (Language Large Model), and + * LLM actually determines which function to call by the description. + * + * Also, when the LLM conversates with the user, the `description` is + * used to explain the function to the user. Therefore, the `description` + * property has the highest priroity, and you have to consider it. + */ + description?: string | undefined; +} +export namespace ILlmFunction { + /** + * Collection of separated parameters. + */ + export interface ISeparated { + /** + * Parameters that would be composed by the LLM. + */ + llm: ISeparatedParameter[]; + + /** + * Parameters that would be composed by the human. + */ + human: ISeparatedParameter[]; + } + + /** + * Separated parameter. + */ + export interface ISeparatedParameter { + /** + * Index of the parameter. + * + * @type uint + */ + index: number; + + /** + * Type schema info of the parameter. + */ + schema: Schema; + } +} diff --git a/src/structures/ILlmSchema.ts b/src/structures/ILlmSchema.ts new file mode 100644 index 0000000..ac388be --- /dev/null +++ b/src/structures/ILlmSchema.ts @@ -0,0 +1,413 @@ +/** + * Type schema info of LLM function call. + * + * `ILlmSchema` is a type metadata of LLM (Large Language Model) + * function calling. + * + * `ILlmSchema` basically follows the JSON schema definition of OpenAPI + * v3.0 specification; {@link OpenApiV3.IJsonSchema}. However, `ILlmSchema` + * does not have the reference type; {@link OpenApiV3.IJsonSchema.IReference}. + * It's because the LLM cannot compose the reference typed arguments. + * + * For reference, the OpenAPI v3.0 based JSON schema definition can't + * express the tuple array type. It has been supported since OpenAPI v3.1; + * {@link OpenApi.IJsonSchema.ITuple}. Therefore, it would better to avoid + * using the tuple array type in the LLM function calling. + * + * @reference https://platform.openai.com/docs/guides/function-calling + * @author Jeongho Nam - https://github.com/samchon + */ +export type ILlmSchema = + | ILlmSchema.IBoolean + | ILlmSchema.IInteger + | ILlmSchema.INumber + | ILlmSchema.IString + | ILlmSchema.IArray + | ILlmSchema.IObject + | ILlmSchema.IUnknown + | ILlmSchema.INullOnly + | ILlmSchema.IOneOf; +export namespace ILlmSchema { + /** + * Boolean type schema info. + */ + export interface IBoolean extends __ISignificant<"boolean"> { + /** + * Default value. + */ + default?: boolean | null; + + /** + * Enumeration values. + */ + enum?: Array; + } + + /** + * Integer type schema info. + */ + export interface IInteger extends __ISignificant<"integer"> { + /** + * Default value. + * + * @type int64 + */ + default?: number | null; + + /** + * Enumeration values. + * + * @type int64 + */ + enum?: Array; + + /** + * Minimum value restriction. + * + * @type int64 + */ + minimum?: number; + + /** + * Maximum value restriction. + * + * @type int64 + */ + maximum?: number; + + /** + * Exclusive minimum value restriction. + * + * For reference, even though your Swagger document has defined the + * `exclusiveMinimum` value as `number`, it has been forcibly converted + * to `boolean` type, and assigns the numeric value to the + * {@link minimum} property in the {@link OpenApi} conversion. + */ + exclusiveMinimum?: boolean; + + /** + * Exclusive maximum value restriction. + * + * For reference, even though your Swagger document has defined the + * `exclusiveMaximum` value as `number`, it has been forcibly converted + * to `boolean` type, and assigns the numeric value to the + * {@link maximum} property in the {@link OpenApi} conversion. + */ + exclusiveMaximum?: boolean; + + /** + * Multiple of value restriction. + * + * @type uint64 + * @exclusiveMinimum 0 + */ + multipleOf?: number; + } + + /** + * Number type schema info. + */ + export interface INumber extends __ISignificant<"number"> { + /** + * Default value. + */ + default?: number | null; + + /** + * Enumeration values. + */ + enum?: Array; + + /** + * Minimum value restriction. + */ + minimum?: number; + + /** + * Maximum value restriction. + */ + maximum?: number; + + /** + * Exclusive minimum value restriction. + * + * For reference, even though your Swagger (or OpenAPI) document has + * defined the `exclusiveMinimum` value as `number`, {@link OpenAiComposer} + * forcibly converts it to `boolean` type, and assign the numeric value to + * the {@link minimum} property. + */ + exclusiveMinimum?: boolean; + + /** + * Exclusive maximum value restriction. + * + * For reference, even though your Swagger (or OpenAPI) document has + * defined the `exclusiveMaximum` value as `number`, {@link OpenAiComposer} + * forcibly converts it to `boolean` type, and assign the numeric value to + * the {@link maximum} property. + */ + exclusiveMaximum?: boolean; + + /** + * Multiple of value restriction. + * + * @exclusiveMinimum 0 + */ + multipleOf?: number; + } + + /** + * String type schema info. + */ + export interface IString extends __ISignificant<"string"> { + /** + * Default value. + */ + default?: string | null; + + /** + * Enumeration values. + */ + enum?: Array; + + /** + * Format restriction. + */ + format?: + | "binary" + | "byte" + | "password" + | "regex" + | "uuid" + | "email" + | "hostname" + | "idn-email" + | "idn-hostname" + | "iri" + | "iri-reference" + | "ipv4" + | "ipv6" + | "uri" + | "uri-reference" + | "uri-template" + | "url" + | "date-time" + | "date" + | "time" + | "duration" + | "json-pointer" + | "relative-json-pointer" + | (string & {}); + + /** + * Pattern restriction. + */ + pattern?: string; + + /** + * Minimum length restriction. + * + * @type uint64 + */ + minLength?: number; + + /** + * Maximum length restriction. + * + * @type uint64 + */ + maxLength?: number; + + /** + * Content media type restriction. + */ + contentMediaType?: string; + } + + /** + * Array type schema info. + */ + export interface IArray + extends __ISignificant<"array"> { + /** + * Items type schema info. + * + * The `items` means the type of the array elements. In other words, it is + * the type schema info of the `T` in the TypeScript array type `Array`. + */ + items: Schema; + + /** + * Unique items restriction. + * + * If this property value is `true`, target array must have unique items. + */ + uniqueItems?: boolean; + + /** + * Minimum items restriction. + * + * Restriction of minumum number of items in the array. + * + * @type uint64 + */ + minItems?: number; + + /** + * Maximum items restriction. + * + * Restriction of maximum number of items in the array. + * + * @type uint64 + */ + maxItems?: number; + } + + /** + * Object type schema info. + */ + export interface IObject + extends __ISignificant<"object"> { + /** + * Properties of the object. + * + * The `properties` means a list of key-value pairs of the object's + * regular properties. The key is the name of the regular property, + * and the value is the type schema info. + * + * If you need additional properties that is represented by dynamic key, + * you can use the {@link additionalProperties} instead. + */ + properties?: Record; + + /** + * List of key values of the required properties. + * + * The `required` means a list of the key values of the required + * {@link properties}. If some property key is not listed in the `required` + * list, it means that property is optional. Otherwise some property key + * exists in the `required` list, it means that the property must be filled. + * + * Below is an example of the {@link properties} and `required`. + * + * ```typescript + * interface SomeObject { + * id: string; + * email: string; + * name?: string; + * } + * ``` + * + * As you can see, `id` and `email` {@link properties} are {@link required}, + * so that they are listed in the `required` list. + * + * ```json + * { + * "type": "object", + * "properties": { + * "id": { "type": "string" }, + * "email": { "type": "string" }, + * "name": { "type": "string" } + * }, + * "required": ["id", "email"] + * } + * ``` + */ + required?: string[]; + + /** + * Additional properties' info. + * + * The `additionalProperties` means the type schema info of the additional + * properties that are not listed in the {@link properties}. + * + * If the value is `true`, it means that the additional properties are not + * restricted. They can be any type. Otherwise, if the value is + * {@link ILlmSchema} type, it means that the additional properties must + * follow the type schema info. + * + * - `true`: `Record` + * - `ILlmSchema`: `Record` + */ + additionalProperties?: boolean | Schema; + } + + /** + * Unknown type schema info. + * + * It means the type of the value is `any`. + */ + export interface IUnknown extends __IAttribute { + /** + * Type is never be defined. + */ + type?: undefined; + } + + /** + * Null only type schema info. + */ + export interface INullOnly extends __IAttribute { + /** + * Type is always `null`. + */ + type: "null"; + + /** + * Default value. + */ + default?: null; + } + + /** + * One of type schema info. + * + * `IOneOf` represents an union type of the TypeScript (`A | B | C`). + * + * For reference, even though your Swagger (or OpenAPI) document has + * defined `anyOf` instead of the `oneOf`, it has been forcibly converted + * to `oneOf` type by {@link OpenApi.convert OpenAPI conversion}. + */ + export interface IOneOf + extends __IAttribute { + /** + * List of the union types. + */ + oneOf: Exclude>[]; + } + + /** + * Significant attributes that can be applied to the most types. + */ + export interface __ISignificant extends __IAttribute { + /** + * Discriminator value of the type. + */ + type: Type; + + /** + * Whether to allow `null` value or not. + */ + nullable?: boolean; + } + + /** + * Common attributes that can be applied to all types. + */ + export interface __IAttribute { + /** + * Title of the schema. + */ + title?: string; + + /** + * Detailed description of the schema. + */ + description?: string; + + /** + * Whether the type is deprecated or not. + */ + deprecated?: boolean; + } +} diff --git a/src/utils/LlmDataMerger.ts b/src/utils/LlmDataMerger.ts new file mode 100644 index 0000000..00729a8 --- /dev/null +++ b/src/utils/LlmDataMerger.ts @@ -0,0 +1,90 @@ +import { IHttpLlmFunction } from "../structures/IHttpLlmFunction"; +import { ILlmFunction } from "../structures/ILlmFunction"; + +/** + * Data combiner for LLM function call. + * + * @author Samchon + */ +export namespace LlmDataMerger { + /** + * Properties of {@link parameters} function. + */ + export interface IProps { + /** + * Target function to call. + */ + function: ILlmFunction; + + /** + * Arguments composed by LLM (Large Language Model). + */ + llm: any[]; + + /** + * Arguments composed by human. + */ + human: any[]; + } + + /** + * Combine LLM and human arguments into one. + * + * When you composes {@link IOpenAiDocument} with + * {@link IOpenAiDocument.IOptions.separate} option, then the arguments of the + * target function would be separated into two parts; LLM (Large Language Model) + * and human. + * + * In that case, you can combine both LLM and human composed arguments into one + * by utilizing this {@link LlmDataMerger.parameters} function, referencing + * the target function metadata {@link IOpenAiFunction.separated}. + * + * @param props Properties to combine LLM and human arguments with metadata. + * @returns Combined arguments + */ + export const parameters = (props: IProps): unknown[] => { + const separated: IHttpLlmFunction.ISeparated | undefined = + props.function.separated; + if (separated === undefined) + throw new Error( + "Error on OpenAiDataComposer.parameters(): the function parameters are not separated.", + ); + return new Array(props.function.parameters.length).fill(0).map((_, i) => { + const llm: number = separated.llm.findIndex((p) => p.index === i); + const human: number = separated.human.findIndex((p) => p.index === i); + if (llm === -1 && human === -1) + throw new Error( + "Error on OpenAiDataComposer.parameters(): failed to gather separated arguments, because both LLM and human sides are all empty.", + ); + return value(props.llm[llm], props.human[human]); + }); + }; + + /** + * Combine two values into one. + * + * If both values are objects, then combines them in the properties level. + * + * Otherwise, returns the latter value if it's not null, otherwise the former value + * + * - `return (y ?? x)` + * + * @param x Value X + * @param y Value Y + * @returns Combined value + */ + export const value = (x: unknown, y: unknown): unknown => + typeof x === "object" && typeof y === "object" && x !== null && y !== null + ? combineObject(x, y) + : Array.isArray(x) && Array.isArray(y) + ? new Array(Math.max(x.length, y.length)) + .fill(0) + .map((_, i) => value(x[i], y[i])) + : y ?? x; + + const combineObject = (x: any, y: any): any => { + const output: any = { ...x }; + for (const [k, v] of Object.entries(y)) output[k] = value(x[k], v); + return output; + }; +} diff --git a/src/utils/LlmSchemaSeparator.ts b/src/utils/LlmSchemaSeparator.ts new file mode 100644 index 0000000..66bf824 --- /dev/null +++ b/src/utils/LlmSchemaSeparator.ts @@ -0,0 +1,92 @@ +import { IHttpLlmFunction } from "../structures/IHttpLlmFunction"; +import { ILlmSchema } from "../structures/ILlmSchema"; +import { LlmTypeChecker } from "./LlmTypeChecker"; + +export namespace LlmSchemaSeparator { + export interface IProps { + parameters: ILlmSchema[]; + predicator: (schema: ILlmSchema) => boolean; + } + export const parameters = (props: IProps): IHttpLlmFunction.ISeparated => { + const indexes: Array<[ILlmSchema | null, ILlmSchema | null]> = + props.parameters.map(schema(props.predicator)); + return { + llm: indexes + .map(([llm], index) => ({ + index, + schema: llm!, + })) + .filter(({ schema }) => schema !== null), + human: indexes + .map(([, human], index) => ({ + index, + schema: human!, + })) + .filter(({ schema }) => schema !== null), + }; + }; + + export const schema = + (predicator: (schema: ILlmSchema) => boolean) => + (input: ILlmSchema): [ILlmSchema | null, ILlmSchema | null] => { + if (predicator(input) === true) return [null, input]; + else if (LlmTypeChecker.isUnknown(input) || LlmTypeChecker.isOneOf(input)) + return [input, null]; + else if (LlmTypeChecker.isObject(input)) + return separateObject(predicator)(input); + else if (LlmTypeChecker.isArray(input)) + return separateArray(predicator)(input); + return [input, null]; + }; + + const separateArray = + (predicator: (schema: ILlmSchema) => boolean) => + ( + input: ILlmSchema.IArray, + ): [ILlmSchema.IArray | null, ILlmSchema.IArray | null] => { + const [x, y] = schema(predicator)(input.items); + return [ + x !== null ? { ...input, items: x } : null, + y !== null ? { ...input, items: y } : null, + ]; + }; + + const separateObject = + (predicator: (schema: ILlmSchema) => boolean) => + ( + input: ILlmSchema.IObject, + ): [ILlmSchema.IObject | null, ILlmSchema.IObject | null] => { + if ( + !!input.additionalProperties || + Object.keys(input.properties ?? {}).length === 0 + ) + return [input, null]; + const llm = { + ...input, + properties: {} as Record, + } satisfies ILlmSchema.IObject; + const human = { + ...input, + properties: {} as Record, + } satisfies ILlmSchema.IObject; + for (const [key, value] of Object.entries(input.properties ?? {})) { + const [x, y] = schema(predicator)(value); + if (x !== null) llm.properties[key] = x; + if (y !== null) human.properties[key] = y; + } + return [ + Object.keys(llm.properties).length === 0 ? null : shrinkRequired(llm), + Object.keys(human.properties).length === 0 + ? null + : shrinkRequired(human), + ]; + }; + + const shrinkRequired = (input: ILlmSchema.IObject): ILlmSchema.IObject => { + if (input.required !== undefined) + input.required = input.required.filter( + (key) => input.properties?.[key] !== undefined, + ); + return input; + }; +} diff --git a/src/utils/LlmTypeChecker.ts b/src/utils/LlmTypeChecker.ts new file mode 100644 index 0000000..633403e --- /dev/null +++ b/src/utils/LlmTypeChecker.ts @@ -0,0 +1,132 @@ +import { ILlmSchema } from "../structures/ILlmSchema"; + +/** + * Type checker for LLM type schema. + * + * `LlmSchemaTypeChecker` is a type checker of {@link ILlmSchema}. + * + * @author Samchon + */ +export namespace LlmTypeChecker { + /** + * Visit every nested schemas. + * + * Visit every nested schemas of the target, and apply the callback function + * to them. + * + * If the visitor meets an union type, it will visit every individual schemas + * in the union type. Otherwise meets an object type, it will visit every + * properties and additional properties. If the visitor meets an array type, + * it will visit the item type. + * + * @param schema Target schema to visit + * @param callback Callback function to apply + */ + export const visit = ( + schema: ILlmSchema, + callback: (schema: ILlmSchema) => void, + ): void => { + callback(schema); + if (isOneOf(schema)) schema.oneOf.forEach((s) => visit(s, callback)); + else if (isObject(schema)) { + for (const [_, s] of Object.entries(schema.properties ?? {})) + visit(s, callback); + if ( + typeof schema.additionalProperties === "object" && + schema.additionalProperties !== null + ) + visit(schema.additionalProperties, callback); + } else if (isArray(schema)) visit(schema.items, callback); + }; + + /** + * Test whether the schema is an union type. + * + * @param schema Target schema + * @returns Whether union type or not + */ + export const isOneOf = (schema: ILlmSchema): schema is ILlmSchema.IOneOf => + (schema as ILlmSchema.IOneOf).oneOf !== undefined; + + /** + * Test whether the schema is an object type. + * + * @param schema Target schema + * @returns Whether object type or not + */ + export const isObject = (schema: ILlmSchema): schema is ILlmSchema.IObject => + (schema as ILlmSchema.IObject).type === "object"; + + /** + * Test whether the schema is an array type. + * + * @param schema Target schema + * @returns Whether array type or not + */ + export const isArray = (schema: ILlmSchema): schema is ILlmSchema.IArray => + (schema as ILlmSchema.IArray).type === "array"; + + /** + * Test whether the schema is a boolean type. + * + * @param schema Target schema + * @returns Whether boolean type or not + */ + export const isBoolean = ( + schema: ILlmSchema, + ): schema is ILlmSchema.IBoolean => + (schema as ILlmSchema.IBoolean).type === "boolean"; + + /** + * Test whether the schema is a number type. + * + * @param schema Target schema + * @returns Whether number type or not + */ + export const isNumber = (schema: ILlmSchema): schema is ILlmSchema.INumber => + (schema as ILlmSchema.INumber).type === "number"; + + /** + * Test whether the schema is a string type. + * + * @param schema Target schema + * @returns Whether string type or not + */ + export const isString = (schema: ILlmSchema): schema is ILlmSchema.IString => + (schema as ILlmSchema.IString).type === "string"; + + /** + * Test whether the schema is a null type. + * + * @param schema Target schema + * @returns Whether null type or not + */ + export const isNullOnly = ( + schema: ILlmSchema, + ): schema is ILlmSchema.INullOnly => + (schema as ILlmSchema.INullOnly).type === "null"; + + /** + * Test whether the schema is a nullable type. + * + * @param schema Target schema + * @returns Whether nullable type or not + */ + export const isNullable = (schema: ILlmSchema): boolean => + !isUnknown(schema) && + (isNullOnly(schema) || + (isOneOf(schema) + ? schema.oneOf.some(isNullable) + : schema.nullable === true)); + + /** + * Test whether the schema is an unknown type. + * + * @param schema Target schema + * @returns Whether unknown type or not + */ + export const isUnknown = ( + schema: ILlmSchema, + ): schema is ILlmSchema.IUnknown => + !isOneOf(schema) && (schema as ILlmSchema.IUnknown).type === undefined; +} diff --git a/src/OpenApiTypeChecker.ts b/src/utils/OpenApiTypeChecker.ts similarity index 99% rename from src/OpenApiTypeChecker.ts rename to src/utils/OpenApiTypeChecker.ts index 1d1d4ff..79e79af 100644 --- a/src/OpenApiTypeChecker.ts +++ b/src/utils/OpenApiTypeChecker.ts @@ -1,5 +1,5 @@ -import { OpenApi } from "./OpenApi"; -import { MapUtil } from "./utils/MapUtil"; +import { OpenApi } from "../OpenApi"; +import { MapUtil } from "./MapUtil"; export namespace OpenApiTypeChecker { export const visit = diff --git a/test/controllers/AppController.ts b/test/controllers/AppController.ts new file mode 100644 index 0000000..5a8b718 --- /dev/null +++ b/test/controllers/AppController.ts @@ -0,0 +1,102 @@ +import { + TypedBody, + TypedFormData, + TypedParam, + TypedQuery, + TypedRoute, +} from "@nestia/core"; +import { Controller, Query } from "@nestjs/common"; +import { tags } from "typia"; + +@Controller() +export class AppController { + @TypedRoute.Get(":index/:level/:optimal/parameters") + public parameters( + @TypedParam("index") + index: string & tags.Format<"uri"> & tags.ContentMediaType<"text/html">, + @TypedParam("level") level: number, + @TypedParam("optimal") optimal: boolean, + ) { + return { index, level, optimal }; + } + + @TypedRoute.Get(":index/:level/:optimal/query") + public query( + @TypedParam("index") + index: string & tags.Format<"uri"> & tags.ContentMediaType<"text/html">, + @TypedParam("level") level: number, + @TypedParam("optimal") optimal: boolean, + @TypedQuery() query: IQuery, + ) { + return { index, level, optimal, query }; + } + + @TypedRoute.Post(":index/:level/:optimal/body") + public body( + @TypedParam("index") + index: string & tags.Format<"uri"> & tags.ContentMediaType<"text/html">, + @TypedParam("level") level: number, + @TypedParam("optimal") optimal: boolean, + @TypedBody() body: IBody, + ) { + return { index, level, optimal, body }; + } + + @TypedRoute.Post(":index/:level/:optimal/query/body") + public query_body( + @TypedParam("index") + index: string & tags.Format<"uri"> & tags.ContentMediaType<"text/html">, + @TypedParam("level") level: number, + @TypedParam("optimal") optimal: boolean, + @Query("thumbnail") + thumbnail: string & tags.Format<"uri"> & tags.ContentMediaType<"image/*">, + @TypedQuery() query: { summary: string }, + @TypedBody() body: IBody, + ) { + return { + index, + level, + optimal, + query: { + ...query, + thumbnail, + }, + body, + }; + } + + @TypedRoute.Post(":index/:level/:optimal/multipart") + public query_multipart( + @TypedParam("index") + index: string & tags.Format<"uri"> & tags.ContentMediaType<"text/html">, + @TypedParam("level") level: number, + @TypedParam("optimal") optimal: boolean, + @TypedQuery() query: IQuery, + @TypedFormData.Body() + body: IMultipart, + ) { + return { + index, + level, + optimal, + query, + body: { + ...body, + file: `http://localhost:3000/files/${Date.now()}.raw`, + }, + }; + } +} + +interface IQuery { + summary: string; + thumbnail: string & tags.Format<"uri"> & tags.ContentMediaType<"image/*">; +} +interface IBody { + title: string; + body: string; + draft: boolean; +} +interface IMultipart extends IBody { + file: File; +} diff --git a/test/controllers/AppFilter.ts b/test/controllers/AppFilter.ts new file mode 100644 index 0000000..f4d7b93 --- /dev/null +++ b/test/controllers/AppFilter.ts @@ -0,0 +1,12 @@ +import { ArgumentsHost, Catch, HttpException } from "@nestjs/common"; +import { BaseExceptionFilter } from "@nestjs/core"; + +@Catch() +export class AppFilter extends BaseExceptionFilter { + public async catch(exception: HttpException | Error, host: ArgumentsHost) { + const status: number = + exception instanceof HttpException ? exception.getStatus() : 500; + if (status === 500) console.info(exception); + return super.catch(exception, host); + } +} diff --git a/test/controllers/AppModule.ts b/test/controllers/AppModule.ts new file mode 100644 index 0000000..322f15a --- /dev/null +++ b/test/controllers/AppModule.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; + +import { AppController } from "./AppController"; + +@Module({ + controllers: [AppController], +}) +export class AppModule {} diff --git a/test/examples/execute.ts b/test/examples/execute.ts new file mode 100644 index 0000000..1c47e4f --- /dev/null +++ b/test/examples/execute.ts @@ -0,0 +1,51 @@ +import { + HttpLlm, + IHttpLlmApplication, + IHttpLlmFunction, + OpenApi, + OpenApiV3, + OpenApiV3_1, + SwaggerV2, +} from "@samchon/openapi"; +import fs from "fs"; +import typia from "typia"; + +const main = async (): Promise => { + // read swagger document and validate it + const swagger: + | SwaggerV2.IDocument + | OpenApiV3.IDocument + | OpenApiV3_1.IDocument = JSON.parse( + await fs.promises.readFile("swagger.json", "utf8"), + ); + typia.assert(swagger); + + // convert to emended OpenAPI document, + // and compose LLM function calling application + const document: OpenApi.IDocument = OpenApi.convert(swagger); + const application: IHttpLlmApplication = HttpLlm.application(document); + + // Let's imagine that LLM has selected a function to call + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => f.path === "/bbs/articles" && f.method === "post", + ); + typia.assertGuard(func); + + // actual execution is by yourself + const article = await HttpLlm.execute({ + connection: { + host: "http://localhost:3000", + }, + application, + function: func, + arguments: [ + "general", + { + title: "Hello, world!", + body: "Let's imagine that this argument is composed by LLM.", + }, + ], + }); + console.log("article", article); +}; +main().catch(console.error); diff --git a/test/examples/keyword.ts b/test/examples/keyword.ts new file mode 100644 index 0000000..e875f71 --- /dev/null +++ b/test/examples/keyword.ts @@ -0,0 +1,63 @@ +import { + HttpLlm, + IHttpLlmApplication, + IHttpLlmFunction, + OpenApi, + OpenApiV3, + OpenApiV3_1, + SwaggerV2, +} from "@samchon/openapi"; +import fs from "fs"; +import typia from "typia"; +import { v4 } from "uuid"; + +const main = async (): Promise => { + // read swagger document and validate it + const swagger: + | SwaggerV2.IDocument + | OpenApiV3.IDocument + | OpenApiV3_1.IDocument = JSON.parse( + await fs.promises.readFile("swagger.json", "utf8"), + ); + typia.assert(swagger); // recommended + + // convert to emended OpenAPI document, + // and compose LLM function calling application + const document: OpenApi.IDocument = OpenApi.convert(swagger); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: true, + }); + + // Let's imagine that LLM has selected a function to call + const func: IHttpLlmFunction | undefined = application.functions.find( + // (f) => f.name === "llm_selected_fuction_name" + (f) => f.path === "/bbs/articles/{id}" && f.method === "put", + ); + if (func === undefined) throw new Error("No matched function exists."); + + // actual execution is by yourself + const article = await HttpLlm.execute({ + connection: { + host: "http://localhost:3000", + }, + application, + function: func, + arguments: [ + { + section: "general", + id: v4(), + query: { + language: "en-US", + format: "markdown", + }, + body: { + title: "Hello, world!", + body: "Let's imagine that this argument is composed by LLM.", + thumbnail: null, + }, + }, + ], + }); + console.log("article", article); +}; +main().catch(console.error); diff --git a/test/examples/separate.ts b/test/examples/separate.ts new file mode 100644 index 0000000..feb6e3d --- /dev/null +++ b/test/examples/separate.ts @@ -0,0 +1,71 @@ +import { + HttpLlm, + IHttpLlmApplication, + IHttpLlmFunction, + LlmTypeChecker, + OpenApi, + OpenApiV3, + OpenApiV3_1, + SwaggerV2, +} from "@samchon/openapi"; +import fs from "fs"; +import typia from "typia"; +import { v4 } from "uuid"; + +const main = async (): Promise => { + // read swagger document and validate it + const swagger: + | SwaggerV2.IDocument + | OpenApiV3.IDocument + | OpenApiV3_1.IDocument = JSON.parse( + await fs.promises.readFile("swagger.json", "utf8"), + ); + typia.assert(swagger); // recommended + + // convert to emended OpenAPI document, + // and compose LLM function calling application + const document: OpenApi.IDocument = OpenApi.convert(swagger); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: false, + separate: (schema) => + LlmTypeChecker.isString(schema) && schema.contentMediaType !== undefined, + }); + + // Let's imagine that LLM has selected a function to call + const func: IHttpLlmFunction | undefined = application.functions.find( + // (f) => f.name === "llm_selected_fuction_name" + (f) => f.path === "/bbs/articles/{id}" && f.method === "put", + ); + if (func === undefined) throw new Error("No matched function exists."); + + // actual execution is by yourself + const article = await HttpLlm.execute({ + connection: { + host: "http://localhost:3000", + }, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + // LLM composed parameter values + "general", + v4(), + { + language: "en-US", + format: "markdown", + }, + { + title: "Hello, world!", + content: "Let's imagine that this argument is composed by LLM.", + }, + ], + human: [ + // Human composed parameter values + { thumbnail: "https://example.com/thumbnail.jpg" }, + ], + }), + }); + console.log("article", article); +}; +main().catch(console.error); diff --git a/test/features/llm/test_http_llm_application_keyword.ts b/test/features/llm/test_http_llm_application_keyword.ts new file mode 100644 index 0000000..9127744 --- /dev/null +++ b/test/features/llm/test_http_llm_application_keyword.ts @@ -0,0 +1,33 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpLlmApplication, + IHttpMigrateRoute, + ILlmSchema, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_application_keyword = (): void => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: true, + }); + for (const func of application.functions) { + const route: IHttpMigrateRoute = func.route(); + TestValidator.equals("length")(1)(func.parameters.length); + TestValidator.equals("properties")([ + ...route.parameters.map((p) => p.key), + ...(route.query ? ["query"] : []), + ...(route.body ? ["body"] : []), + ])( + (() => { + const schema: ILlmSchema = func.parameters[0]; + if (!LlmTypeChecker.isObject(schema)) return []; + return Object.keys(schema.properties ?? {}); + })(), + ); + } +}; diff --git a/test/features/llm/test_http_llm_application_positional.ts b/test/features/llm/test_http_llm_application_positional.ts new file mode 100644 index 0000000..074ef13 --- /dev/null +++ b/test/features/llm/test_http_llm_application_positional.ts @@ -0,0 +1,22 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpLlmApplication, + IHttpMigrateRoute, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_application_positional = (): void => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: false, + }); + for (const func of application.functions) { + const route: IHttpMigrateRoute = func.route(); + TestValidator.equals("length")(func.parameters.length)( + route.parameters.length + (route.query ? 1 : 0) + (route.body ? 1 : 0), + ); + } +}; diff --git a/test/features/llm/test_http_llm_fetcher_keyword_body.ts b/test/features/llm/test_http_llm_fetcher_keyword_body.ts new file mode 100644 index 0000000..a6c03bd --- /dev/null +++ b/test/features/llm/test_http_llm_fetcher_keyword_body.ts @@ -0,0 +1,53 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpConnection, + IHttpLlmApplication, + IHttpLlmFunction, + IHttpResponse, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_fetcher_keyword_body = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: true, + separate: (schema) => + LlmTypeChecker.isString(schema) && !!schema.contentMediaType, + }); + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => f.path === "/{index}/{level}/{optimal}/body" && f.method === "post", + ); + if (func === undefined) throw new Error("Function not found"); + + const response: IHttpResponse = await HttpLlm.propagate({ + connection, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + { + level: 123, + optimal: true, + body: { + title: "some title", + body: "some body", + draft: false, + }, + }, + ], + human: [ + { + index: "https://some.url/index.html", + }, + ], + }), + }); + TestValidator.equals("response.status")(response.status)(201); +}; diff --git a/test/features/llm/test_http_llm_fetcher_keyword_parameters.ts b/test/features/llm/test_http_llm_fetcher_keyword_parameters.ts new file mode 100644 index 0000000..50d1a26 --- /dev/null +++ b/test/features/llm/test_http_llm_fetcher_keyword_parameters.ts @@ -0,0 +1,49 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpConnection, + IHttpLlmApplication, + IHttpLlmFunction, + IHttpResponse, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_fetcher_keyword_parameters = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: true, + separate: (schema) => + LlmTypeChecker.isString(schema) && !!schema.contentMediaType, + }); + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => + f.path === "/{index}/{level}/{optimal}/parameters" && f.method === "get", + ); + if (func === undefined) throw new Error("Function not found"); + + const response: IHttpResponse = await HttpLlm.propagate({ + connection, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + { + level: 123, + optimal: true, + }, + ], + human: [ + { + index: "https://some.url/index.html", + }, + ], + }), + }); + TestValidator.equals("response.status")(response.status)(200); +}; diff --git a/test/features/llm/test_http_llm_fetcher_keyword_query.ts b/test/features/llm/test_http_llm_fetcher_keyword_query.ts new file mode 100644 index 0000000..cd81620 --- /dev/null +++ b/test/features/llm/test_http_llm_fetcher_keyword_query.ts @@ -0,0 +1,54 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpConnection, + IHttpLlmApplication, + IHttpLlmFunction, + IHttpResponse, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_fetcher_keyword_query = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: true, + separate: (schema) => + LlmTypeChecker.isString(schema) && !!schema.contentMediaType, + }); + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => f.path === "/{index}/{level}/{optimal}/query" && f.method === "get", + ); + if (func === undefined) throw new Error("Function not found"); + + const response: IHttpResponse = await HttpLlm.propagate({ + connection, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + { + level: 123, + optimal: true, + query: { + summary: "some summary", + }, + }, + ], + human: [ + { + index: "https://some.url/index.html", + query: { + thumbnail: "https://some.url/file.jpg", + }, + }, + ], + }), + }); + TestValidator.equals("response.status")(response.status)(200); +}; diff --git a/test/features/llm/test_http_llm_fetcher_keyword_query_and_body.ts b/test/features/llm/test_http_llm_fetcher_keyword_query_and_body.ts new file mode 100644 index 0000000..2dcf74e --- /dev/null +++ b/test/features/llm/test_http_llm_fetcher_keyword_query_and_body.ts @@ -0,0 +1,60 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpConnection, + IHttpLlmApplication, + IHttpLlmFunction, + IHttpResponse, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_fetcher_keyword_query_and_body = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: true, + separate: (schema) => + LlmTypeChecker.isString(schema) && !!schema.contentMediaType, + }); + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => + f.path === "/{index}/{level}/{optimal}/query/body" && f.method === "post", + ); + if (func === undefined) throw new Error("Function not found"); + + const response: IHttpResponse = await HttpLlm.propagate({ + connection, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + { + level: 123, + optimal: true, + query: { + summary: "some summary", + }, + body: { + title: "some title", + body: "some body", + draft: false, + }, + }, + ], + human: [ + { + index: "https://some.url/index.html", + query: { + thumbnail: "https://some.url/file.jpg", + }, + }, + ], + }), + }); + TestValidator.equals("response.status")(response.status)(201); +}; diff --git a/test/features/llm/test_http_llm_fetcher_positional_body.ts b/test/features/llm/test_http_llm_fetcher_positional_body.ts new file mode 100644 index 0000000..7932fac --- /dev/null +++ b/test/features/llm/test_http_llm_fetcher_positional_body.ts @@ -0,0 +1,47 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpConnection, + IHttpLlmApplication, + IHttpLlmFunction, + IHttpResponse, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_fetcher_positional_body = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: false, + separate: (schema) => + LlmTypeChecker.isString(schema) && !!schema.contentMediaType, + }); + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => f.path === "/{index}/{level}/{optimal}/body" && f.method === "post", + ); + if (func === undefined) throw new Error("Function not found"); + + const response: IHttpResponse = await HttpLlm.propagate({ + connection, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + 123, + true, + { + title: "some title", + body: "some body", + draft: false, + }, + ], + human: ["https://some.url/index.html"], + }), + }); + TestValidator.equals("response.status")(response.status)(201); +}; diff --git a/test/features/llm/test_http_llm_fetcher_positional_parameters.ts b/test/features/llm/test_http_llm_fetcher_positional_parameters.ts new file mode 100644 index 0000000..03d19b6 --- /dev/null +++ b/test/features/llm/test_http_llm_fetcher_positional_parameters.ts @@ -0,0 +1,40 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpConnection, + IHttpLlmApplication, + IHttpLlmFunction, + IHttpResponse, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_fetcher_keyword_parameters = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: false, + separate: (schema) => + LlmTypeChecker.isString(schema) && !!schema.contentMediaType, + }); + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => + f.path === "/{index}/{level}/{optimal}/parameters" && f.method === "get", + ); + if (func === undefined) throw new Error("Function not found"); + + const response: IHttpResponse = await HttpLlm.propagate({ + connection, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [123, true], + human: ["https://some.url/index.html"], + }), + }); + TestValidator.equals("response.status")(response.status)(200); +}; diff --git a/test/features/llm/test_http_llm_fetcher_positional_query.ts b/test/features/llm/test_http_llm_fetcher_positional_query.ts new file mode 100644 index 0000000..2bbfb23 --- /dev/null +++ b/test/features/llm/test_http_llm_fetcher_positional_query.ts @@ -0,0 +1,50 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpConnection, + IHttpLlmApplication, + IHttpLlmFunction, + IHttpResponse, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_fetcher_keyword_query = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: false, + separate: (schema) => + LlmTypeChecker.isString(schema) && !!schema.contentMediaType, + }); + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => f.path === "/{index}/{level}/{optimal}/query" && f.method === "get", + ); + if (func === undefined) throw new Error("Function not found"); + + const response: IHttpResponse = await HttpLlm.propagate({ + connection, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + 123, + true, + { + summary: "some summary", + }, + ], + human: [ + "https://some.url/index.html", + { + thumbnail: "https://some.url/file.jpg", + }, + ], + }), + }); + TestValidator.equals("response.status")(response.status)(200); +}; diff --git a/test/features/llm/test_http_llm_fetcher_positional_query_and_body.ts b/test/features/llm/test_http_llm_fetcher_positional_query_and_body.ts new file mode 100644 index 0000000..147d5ba --- /dev/null +++ b/test/features/llm/test_http_llm_fetcher_positional_query_and_body.ts @@ -0,0 +1,56 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpLlm, + IHttpConnection, + IHttpLlmApplication, + IHttpLlmFunction, + IHttpResponse, + LlmTypeChecker, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_llm_fetcher_keyword_query_and_body = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const application: IHttpLlmApplication = HttpLlm.application(document, { + keyword: false, + separate: (schema) => + LlmTypeChecker.isString(schema) && !!schema.contentMediaType, + }); + const func: IHttpLlmFunction | undefined = application.functions.find( + (f) => + f.path === "/{index}/{level}/{optimal}/query/body" && f.method === "post", + ); + if (func === undefined) throw new Error("Function not found"); + + const response: IHttpResponse = await HttpLlm.propagate({ + connection, + application, + function: func, + arguments: HttpLlm.mergeParameters({ + function: func, + llm: [ + 123, + true, + { + summary: "some summary", + }, + { + title: "some title", + body: "some body", + draft: false, + }, + ], + human: [ + "https://some.url/index.html", + { + thumbnail: "https://some.url/file.jpg", + }, + ], + }), + }); + TestValidator.equals("response.status")(response.status)(201); +}; diff --git a/test/features/llm/test_llm_merge_parameters.ts b/test/features/llm/test_llm_merge_parameters.ts new file mode 100644 index 0000000..759a6a6 --- /dev/null +++ b/test/features/llm/test_llm_merge_parameters.ts @@ -0,0 +1,42 @@ +import { TestValidator } from "@nestia/e2e"; +import { HttpLlm } from "@samchon/openapi"; + +export const test_llm_merge_parameters = (): void => { + TestValidator.equals("atomics")( + HttpLlm.mergeParameters({ + function: { + name: "test", + parameters: [ + { type: "boolean" }, + { type: "number" }, + { type: "string" }, + { type: "string" }, + ], + separated: { + human: [ + { + schema: { type: "boolean" }, + index: 0, + }, + { + schema: { type: "number" }, + index: 1, + }, + ], + llm: [ + { + schema: { type: "string" }, + index: 2, + }, + { + schema: { type: "string" }, + index: 3, + }, + ], + }, + }, + human: [false, 1], + llm: ["two", "three"], + }), + )([false, 1, "two", "three"]); +}; diff --git a/test/features/llm/test_llm_merge_value.ts b/test/features/llm/test_llm_merge_value.ts new file mode 100644 index 0000000..e7239ea --- /dev/null +++ b/test/features/llm/test_llm_merge_value.ts @@ -0,0 +1,62 @@ +import { TestValidator } from "@nestia/e2e"; +import { HttpLlm } from "@samchon/openapi"; + +export const test_llm_merge_parameters = (): void => { + TestValidator.equals("number")(HttpLlm.mergeValue(1, 2))(2); + TestValidator.equals("nullable")(HttpLlm.mergeValue(0, null))(0); + TestValidator.equals("optional")(HttpLlm.mergeValue(0, undefined))(0); + TestValidator.equals("object")( + HttpLlm.mergeValue( + { + a: "A", + array: [1, 2, 3], + nestedArray: [{ alpha: "alpha" }, { alpha: "alpha" }], + object: { x: "X" }, + }, + { + b: "B", + array: [3, 4, 5], + nestedArray: [{ beta: "beta" }, { beta: "beta" }], + object: { y: "Y" }, + }, + ), + )({ + a: "A", + b: "B", + array: [3, 4, 5], + nestedArray: [ + { + alpha: "alpha", + beta: "beta", + }, + { + alpha: "alpha", + beta: "beta", + }, + ], + object: { x: "X", y: "Y" }, + }); + TestValidator.equals("membership")( + HttpLlm.mergeValue( + { + name: "Samchon", + email: "samchon.github@gmail.com", + password: "1234", + age: 30, + gender: 1, + }, + { + homepage: "https://github.com/samchon", + secret: "something", + }, + ), + )({ + name: "Samchon", + email: "samchon.github@gmail.com", + password: "1234", + age: 30, + gender: 1, + homepage: "https://github.com/samchon", + secret: "something", + }); +}; diff --git a/test/features/llm/test_llm_schema_object.ts b/test/features/llm/test_llm_schema_object.ts new file mode 100644 index 0000000..0847f2e --- /dev/null +++ b/test/features/llm/test_llm_schema_object.ts @@ -0,0 +1,55 @@ +import { TestValidator } from "@nestia/e2e"; +import { HttpLlm, ILlmSchema } from "@samchon/openapi"; +import typia, { IJsonApplication, tags } from "typia"; + +export const test_llm_schema_object = (): void => { + const app: IJsonApplication = typia.json.application<[First]>(); + const schema: ILlmSchema | null = HttpLlm.schema({ + components: app.components, + schema: app.schemas[0], + }); + TestValidator.equals("schema")(schema)({ + type: "object", + required: ["second"], + properties: { + second: { + type: "object", + required: ["third"], + description: "The second property", + properties: { + third: { + type: "object", + required: ["id"], + description: "The third property", + properties: { + id: { + type: "string", + format: "uuid", + description: "Hello word", + }, + }, + }, + }, + }, + }, + }); +}; + +interface First { + /** + * The second property + */ + second: Second; +} +interface Second { + /** + * The third property + */ + third: Third; +} +interface Third { + /** + * Hello word + */ + id: string & tags.Format<"uuid">; +} diff --git a/test/features/llm/test_llm_schema_oneof.ts b/test/features/llm/test_llm_schema_oneof.ts new file mode 100644 index 0000000..d03153c --- /dev/null +++ b/test/features/llm/test_llm_schema_oneof.ts @@ -0,0 +1,77 @@ +import { TestValidator } from "@nestia/e2e"; +import { HttpLlm, ILlmSchema } from "@samchon/openapi"; +import typia, { IJsonApplication } from "typia"; + +export const test_llm_schema_oneof = (): void => { + const app: IJsonApplication = + typia.json.application<[Circle | Triangle | Rectangle]>(); + const casted: ILlmSchema | null = HttpLlm.schema({ + components: app.components, + schema: app.schemas[0], + }); + TestValidator.equals("oneOf")(casted)({ + oneOf: [ + { + type: "object", + properties: { + type: { + type: "string", + enum: ["circle"], + }, + radius: { + type: "number", + }, + }, + required: ["type", "radius"], + }, + { + type: "object", + properties: { + type: { + type: "string", + enum: ["triangle"], + }, + base: { + type: "number", + }, + height: { + type: "number", + }, + }, + required: ["type", "base", "height"], + }, + { + type: "object", + properties: { + type: { + type: "string", + enum: ["square"], + }, + width: { + type: "number", + }, + height: { + type: "number", + }, + }, + required: ["type", "width", "height"], + }, + ], + ...{ discriminator: undefined }, + }); +}; + +interface Circle { + type: "circle"; + radius: number; +} +interface Triangle { + type: "triangle"; + base: number; + height: number; +} +interface Rectangle { + type: "square"; + width: number; + height: number; +} diff --git a/test/features/llm/test_llm_schema_separate_array.ts b/test/features/llm/test_llm_schema_separate_array.ts new file mode 100644 index 0000000..eb7fc1f --- /dev/null +++ b/test/features/llm/test_llm_schema_separate_array.ts @@ -0,0 +1,38 @@ +import { TestValidator } from "@nestia/e2e"; +import { HttpLlm, ILlmSchema, LlmTypeChecker, OpenApi } from "@samchon/openapi"; +import { LlmSchemaSeparator } from "@samchon/openapi/lib/utils/LlmSchemaSeparator"; +import typia, { tags } from "typia"; + +export const test_llm_schema_separate_array = (): void => { + const separator = LlmSchemaSeparator.schema( + (s) => LlmTypeChecker.isString(s) && s.contentMediaType !== undefined, + ); + const member: ILlmSchema = schema(typia.json.application<[IMember[]]>()); + const upload: ILlmSchema = schema(typia.json.application<[IFileUpload[]]>()); + const combined: ILlmSchema = schema(typia.json.application<[ICombined[]]>()); + + TestValidator.equals("member")(separator(member))([member, null]); + TestValidator.equals("upload")(separator(upload))([null, upload]); + TestValidator.equals("combined")(separator(combined))([member, upload]); +}; + +interface IMember { + id: number; + name: string; +} +interface IFileUpload { + file: string & tags.ContentMediaType<"image/png">; +} +interface ICombined extends IMember, IFileUpload {} + +const schema = (props: { + components: OpenApi.IComponents; + schemas: OpenApi.IJsonSchema[]; +}): ILlmSchema => { + const schema: ILlmSchema | null = HttpLlm.schema({ + components: props.components, + schema: props.schemas[0], + }); + if (schema === null) throw new Error("Invalid schema"); + return schema; +}; diff --git a/test/features/llm/test_llm_schema_separate_nested.ts b/test/features/llm/test_llm_schema_separate_nested.ts new file mode 100644 index 0000000..816d8a2 --- /dev/null +++ b/test/features/llm/test_llm_schema_separate_nested.ts @@ -0,0 +1,54 @@ +import { TestValidator } from "@nestia/e2e"; +import { HttpLlm, ILlmSchema, LlmTypeChecker, OpenApi } from "@samchon/openapi"; +import { LlmSchemaSeparator } from "@samchon/openapi/lib/utils/LlmSchemaSeparator"; +import typia, { tags } from "typia"; + +export const test_llm_schema_separate_nested = (): void => { + const separator = LlmSchemaSeparator.schema( + (s) => LlmTypeChecker.isString(s) && s.contentMediaType !== undefined, + ); + const member: ILlmSchema = schema( + typia.json.application<[INested]>(), + ); + const upload: ILlmSchema = schema( + typia.json.application<[INested]>(), + ); + const combined: ILlmSchema = schema( + typia.json.application<[INested]>(), + ); + + TestValidator.equals("member")(separator(member))([member, null]); + TestValidator.equals("upload")(separator(upload))([null, upload]); + TestValidator.equals("combined")(separator(combined))([member, upload]); +}; + +interface INested { + first: { + second: { + third: { + fourth: T; + }; + array: T[]; + }; + }; +} +interface IMember { + id: number; + name: string; +} +interface IFileUpload { + file: string & tags.Format<"uri"> & tags.ContentMediaType<"image/png">; +} +interface ICombined extends IMember, IFileUpload {} + +const schema = (props: { + components: OpenApi.IComponents; + schemas: OpenApi.IJsonSchema[]; +}): ILlmSchema => { + const schema: ILlmSchema | null = HttpLlm.schema({ + components: props.components, + schema: props.schemas[0], + }); + if (schema === null) throw new Error("Invalid schema"); + return schema; +}; diff --git a/test/features/llm/test_llm_schema_separate_object.ts b/test/features/llm/test_llm_schema_separate_object.ts new file mode 100644 index 0000000..abe7802 --- /dev/null +++ b/test/features/llm/test_llm_schema_separate_object.ts @@ -0,0 +1,38 @@ +import { TestValidator } from "@nestia/e2e"; +import { HttpLlm, ILlmSchema, LlmTypeChecker, OpenApi } from "@samchon/openapi"; +import { LlmSchemaSeparator } from "@samchon/openapi/lib/utils/LlmSchemaSeparator"; +import typia, { tags } from "typia"; + +export const test_llm_schema_separate_object = (): void => { + const separator = LlmSchemaSeparator.schema( + (s) => LlmTypeChecker.isString(s) && s.contentMediaType !== undefined, + ); + const member: ILlmSchema = schema(typia.json.application<[IMember]>()); + const upload: ILlmSchema = schema(typia.json.application<[IFileUpload]>()); + const combined: ILlmSchema = schema(typia.json.application<[ICombined]>()); + + TestValidator.equals("member")(separator(member))([member, null]); + TestValidator.equals("upload")(separator(upload))([null, upload]); + TestValidator.equals("combined")(separator(combined))([member, upload]); +}; + +interface IMember { + id: number; + name: string; +} +interface IFileUpload { + file: string & tags.Format<"uri"> & tags.ContentMediaType<"image/png">; +} +interface ICombined extends IMember, IFileUpload {} + +const schema = (props: { + components: OpenApi.IComponents; + schemas: OpenApi.IJsonSchema[]; +}): ILlmSchema => { + const schema: ILlmSchema | null = HttpLlm.schema({ + components: props.components, + schema: props.schemas[0], + }); + if (schema === null) throw new Error("Invalid schema"); + return schema; +}; diff --git a/test/features/llm/test_llm_schema_separate_string.ts b/test/features/llm/test_llm_schema_separate_string.ts new file mode 100644 index 0000000..ba4c6e1 --- /dev/null +++ b/test/features/llm/test_llm_schema_separate_string.ts @@ -0,0 +1,17 @@ +import { TestValidator } from "@nestia/e2e"; +import { ILlmSchema, LlmTypeChecker } from "@samchon/openapi"; +import { LlmSchemaSeparator } from "@samchon/openapi/lib/utils/LlmSchemaSeparator"; + +export const test_schema_separate_string = (): void => { + const separator = LlmSchemaSeparator.schema( + (s) => LlmTypeChecker.isString(s) && s.contentMediaType !== undefined, + ); + const plain: ILlmSchema = { type: "string" }; + const upload: ILlmSchema = { + type: "string", + format: "uri", + contentMediaType: "image/png", + }; + TestValidator.equals("plain")(separator(plain))([plain, null]); + TestValidator.equals("upload")(separator(upload))([null, upload]); +}; diff --git a/test/features/migrate/test_http_migrate_fetch_body.ts b/test/features/migrate/test_http_migrate_fetch_body.ts new file mode 100644 index 0000000..57fbecf --- /dev/null +++ b/test/features/migrate/test_http_migrate_fetch_body.ts @@ -0,0 +1,38 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpMigration, + IHttpMigrateApplication, + IHttpMigrateRoute, +} from "@samchon/openapi"; +import { OpenApi } from "@samchon/openapi/lib/OpenApi"; +import { IHttpConnection } from "@samchon/openapi/lib/structures/IHttpConnection"; +import { IHttpResponse } from "@samchon/openapi/lib/structures/IHttpResponse"; + +import swagger from "../../swagger.json"; + +export const test_http_migrate_fetch_body = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const app: IHttpMigrateApplication = HttpMigration.application(document); + const route: IHttpMigrateRoute | undefined = app.routes.find( + (r) => r.path === "/{index}/{level}/{optimal}/body" && r.method === "post", + ); + if (route === undefined) throw new Error("Route not found"); + + const response: IHttpResponse = await HttpMigration.propagate({ + connection, + route, + parameters: { + index: "https://some.url/index.html", + level: 123, + optimal: true, + }, + body: { + title: "some title", + body: "some body", + draft: false, + }, + }); + TestValidator.equals("response.status")(response.status)(201); +}; diff --git a/test/features/migrate/test_http_migrate_fetch_keyword_parameters.ts b/test/features/migrate/test_http_migrate_fetch_keyword_parameters.ts new file mode 100644 index 0000000..6551139 --- /dev/null +++ b/test/features/migrate/test_http_migrate_fetch_keyword_parameters.ts @@ -0,0 +1,31 @@ +import { + HttpMigration, + IHttpConnection, + IHttpMigrateApplication, + IHttpMigrateRoute, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_migrate_fetch_keyword_parameters = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const app: IHttpMigrateApplication = HttpMigration.application(document); + const route: IHttpMigrateRoute | undefined = app.routes.find( + (r) => + r.path === "/{index}/{level}/{optimal}/parameters" && r.method === "get", + ); + if (route === undefined) throw new Error("Route not found"); + + await HttpMigration.execute({ + connection, + route, + parameters: { + index: "https://some.url/index.html", + level: 2, + optimal: true, + }, + }); +}; diff --git a/test/features/migrate/test_http_migrate_fetch_multipart.ts b/test/features/migrate/test_http_migrate_fetch_multipart.ts new file mode 100644 index 0000000..63f268e --- /dev/null +++ b/test/features/migrate/test_http_migrate_fetch_multipart.ts @@ -0,0 +1,41 @@ +import { + HttpMigration, + IHttpConnection, + IHttpMigrateApplication, + IHttpMigrateRoute, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_migrate_fetch_multipart = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const app: IHttpMigrateApplication = HttpMigration.application(document); + const route: IHttpMigrateRoute | undefined = app.routes.find( + (r) => + r.path === "/{index}/{level}/{optimal}/multipart" && r.method === "post", + ); + if (route === undefined) throw new Error("Route not found"); + + await HttpMigration.execute({ + connection, + route, + parameters: { + index: "https://some.url/index.html", + level: 2, + optimal: true, + }, + query: { + summary: "some summary", + thumbnail: "https://some.url", + }, + body: { + title: "some title", + body: "some body", + draft: false, + file: new File([new Uint8Array(999).fill(1)], "file.txt"), + }, + }); +}; diff --git a/test/features/migrate/test_http_migrate_fetch_positional_parameters.ts b/test/features/migrate/test_http_migrate_fetch_positional_parameters.ts new file mode 100644 index 0000000..f20d839 --- /dev/null +++ b/test/features/migrate/test_http_migrate_fetch_positional_parameters.ts @@ -0,0 +1,27 @@ +import { + HttpMigration, + IHttpConnection, + IHttpMigrateApplication, + IHttpMigrateRoute, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_migrate_fetch_positional_parameters = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const app: IHttpMigrateApplication = HttpMigration.application(document); + const route: IHttpMigrateRoute | undefined = app.routes.find( + (r) => + r.path === "/{index}/{level}/{optimal}/parameters" && r.method === "get", + ); + if (route === undefined) throw new Error("Route not found"); + + await HttpMigration.execute({ + connection, + route, + parameters: ["https://some.url/index.html", 2, true], + }); +}; diff --git a/test/features/migrate/test_http_migrate_fetch_propagate.ts b/test/features/migrate/test_http_migrate_fetch_propagate.ts new file mode 100644 index 0000000..06755a5 --- /dev/null +++ b/test/features/migrate/test_http_migrate_fetch_propagate.ts @@ -0,0 +1,30 @@ +import { TestValidator } from "@nestia/e2e"; +import { + HttpMigration, + IHttpConnection, + IHttpMigrateApplication, + IHttpMigrateRoute, + IHttpResponse, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_migrate_fetch_propagate = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const app: IHttpMigrateApplication = HttpMigration.application(document); + const route: IHttpMigrateRoute | undefined = app.routes.find( + (r) => + r.path === "/{index}/{level}/{optimal}/parameters" && r.method === "get", + ); + if (route === undefined) throw new Error("Route not found"); + + const response: IHttpResponse = await HttpMigration.propagate({ + connection, + route, + parameters: ["https://some.url/index.html", "two", "one"], + }); + TestValidator.equals("status")(response.status)(400); +}; diff --git a/test/features/migrate/test_http_migrate_fetch_query.ts b/test/features/migrate/test_http_migrate_fetch_query.ts new file mode 100644 index 0000000..5df5243 --- /dev/null +++ b/test/features/migrate/test_http_migrate_fetch_query.ts @@ -0,0 +1,34 @@ +import { + HttpMigration, + IHttpConnection, + IHttpMigrateApplication, + IHttpMigrateRoute, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_migrate_fetch_query = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const app: IHttpMigrateApplication = HttpMigration.application(document); + const route: IHttpMigrateRoute | undefined = app.routes.find( + (r) => r.path === "/{index}/{level}/{optimal}/query" && r.method === "get", + ); + if (route === undefined) throw new Error("Route not found"); + + await HttpMigration.execute({ + connection, + route, + parameters: { + index: "https://some.url/index.html", + level: 123, + optimal: true, + }, + query: { + summary: "some summary", + thumbnail: "https://some.url", + }, + }); +}; diff --git a/test/features/migrate/test_http_migrate_fetch_query_and_body.ts b/test/features/migrate/test_http_migrate_fetch_query_and_body.ts new file mode 100644 index 0000000..8c1c1dd --- /dev/null +++ b/test/features/migrate/test_http_migrate_fetch_query_and_body.ts @@ -0,0 +1,40 @@ +import { + HttpMigration, + IHttpConnection, + IHttpMigrateApplication, + IHttpMigrateRoute, + OpenApi, +} from "@samchon/openapi"; + +import swagger from "../../swagger.json"; + +export const test_http_migrate_fetch_query_and_body = async ( + connection: IHttpConnection, +): Promise => { + const document: OpenApi.IDocument = OpenApi.convert(swagger as any); + const app: IHttpMigrateApplication = HttpMigration.application(document); + const route: IHttpMigrateRoute | undefined = app.routes.find( + (r) => + r.path === "/{index}/{level}/{optimal}/query/body" && r.method === "post", + ); + if (route === undefined) throw new Error("Route not found"); + + await HttpMigration.execute({ + connection, + route, + parameters: { + index: "https://some.url/index.html", + level: 123, + optimal: true, + }, + query: { + summary: "some summary", + thumbnail: "https://some.url", + }, + body: { + title: "some title", + body: "some body", + draft: false, + }, + }); +}; diff --git a/test/features/test_document_migrate_route_comment.ts b/test/features/migrate/test_http_migrate_route_comment.ts similarity index 76% rename from test/features/test_document_migrate_route_comment.ts rename to test/features/migrate/test_http_migrate_route_comment.ts index bb15eaf..9750a09 100644 --- a/test/features/test_document_migrate_route_comment.ts +++ b/test/features/migrate/test_http_migrate_route_comment.ts @@ -1,18 +1,23 @@ import { TestValidator } from "@nestia/e2e"; -import { IMigrateDocument, IMigrateRoute, OpenApi } from "@samchon/openapi"; +import { + HttpMigration, + IHttpMigrateApplication, + IHttpMigrateRoute, + OpenApi, +} from "@samchon/openapi"; import fs from "fs"; -export const test_document_migrate_route_comment = async (): Promise => { +export const test_http_migrate_route_comment = async (): Promise => { const swagger: OpenApi.IDocument = OpenApi.convert( JSON.parse( await fs.promises.readFile( - `${__dirname}/../../../examples/v3.1/shopping.json`, + `${__dirname}/../../../../examples/v3.1/shopping.json`, "utf8", ), ), ); - const migrate: IMigrateDocument = OpenApi.migrate(swagger); - const route: IMigrateRoute | undefined = migrate.routes.find( + const migrate: IHttpMigrateApplication = HttpMigration.application(swagger); + const route: IHttpMigrateRoute | undefined = migrate.routes.find( (r) => r.path === "/shoppings/sellers/sales/{id}" && r.method === "put", ); TestValidator.equals("comment")(route?.comment())(EXPECTED); diff --git a/test/features/test_document_migrate_v20.ts b/test/features/migrate/test_http_migrate_v20.ts similarity index 56% rename from test/features/test_document_migrate_v20.ts rename to test/features/migrate/test_http_migrate_v20.ts index 8a6b083..d95263d 100644 --- a/test/features/test_document_migrate_v20.ts +++ b/test/features/migrate/test_http_migrate_v20.ts @@ -1,16 +1,21 @@ -import { IMigrateDocument, OpenApi, SwaggerV2 } from "@samchon/openapi"; +import { + HttpMigration, + IHttpMigrateApplication, + OpenApi, + SwaggerV2, +} from "@samchon/openapi"; import fs from "fs"; import typia from "typia"; -export const test_document_migrate_v20 = async (): Promise => { - const path: string = `${__dirname}/../../../examples/v2.0`; +export const test_http_migrate_v20 = async (): Promise => { + const path: string = `${__dirname}/../../../../examples/v2.0`; for (const file of await fs.promises.readdir(path)) { if (file.endsWith(".json") === false) continue; const swagger: SwaggerV2.IDocument = typia.assert( JSON.parse(await fs.promises.readFile(`${path}/${file}`, "utf8")), ); const openapi: OpenApi.IDocument = OpenApi.convert(swagger); - const migrate: IMigrateDocument = OpenApi.migrate(openapi); + const migrate: IHttpMigrateApplication = HttpMigration.application(openapi); typia.assert(migrate); } }; diff --git a/test/features/test_document_migrate_v30.ts b/test/features/migrate/test_http_migrate_v30.ts similarity index 56% rename from test/features/test_document_migrate_v30.ts rename to test/features/migrate/test_http_migrate_v30.ts index de3a2e6..ecdf998 100644 --- a/test/features/test_document_migrate_v30.ts +++ b/test/features/migrate/test_http_migrate_v30.ts @@ -1,16 +1,21 @@ -import { IMigrateDocument, OpenApi, OpenApiV3 } from "@samchon/openapi"; +import { + HttpMigration, + IHttpMigrateApplication, + OpenApi, + OpenApiV3, +} from "@samchon/openapi"; import fs from "fs"; import typia from "typia"; -export const test_document_migrate_v30 = async (): Promise => { - const path: string = `${__dirname}/../../../examples/v3.0`; +export const test_http_migrate_v30 = async (): Promise => { + const path: string = `${__dirname}/../../../../examples/v3.0`; for (const file of await fs.promises.readdir(path)) { if (file.endsWith(".json") === false) continue; const swagger: OpenApiV3.IDocument = typia.assert( JSON.parse(await fs.promises.readFile(`${path}/${file}`, "utf8")), ); const openapi: OpenApi.IDocument = OpenApi.convert(swagger); - const migrate: IMigrateDocument = OpenApi.migrate(openapi); + const migrate: IHttpMigrateApplication = HttpMigration.application(openapi); typia.assert(migrate); } }; diff --git a/test/features/test_document_migrate_v31.ts b/test/features/migrate/test_http_migrate_v31.ts similarity index 56% rename from test/features/test_document_migrate_v31.ts rename to test/features/migrate/test_http_migrate_v31.ts index fdd9d5c..0a9f3d8 100644 --- a/test/features/test_document_migrate_v31.ts +++ b/test/features/migrate/test_http_migrate_v31.ts @@ -1,16 +1,21 @@ -import { IMigrateDocument, OpenApi, OpenApiV3_1 } from "@samchon/openapi"; +import { + HttpMigration, + IHttpMigrateApplication, + OpenApi, + OpenApiV3_1, +} from "@samchon/openapi"; import fs from "fs"; import typia from "typia"; -export const test_document_migrate_v31 = async (): Promise => { - const path: string = `${__dirname}/../../../examples/v3.1`; +export const test_http_migrate_v31 = async (): Promise => { + const path: string = `${__dirname}/../../../../examples/v3.1`; for (const file of await fs.promises.readdir(path)) { if (file.endsWith(".json") === false) continue; const swagger: OpenApiV3_1.IDocument = typia.assert( JSON.parse(await fs.promises.readFile(`${path}/${file}`, "utf8")), ); const openapi: OpenApi.IDocument = OpenApi.convert(swagger); - const migrate: IMigrateDocument = OpenApi.migrate(openapi); + const migrate: IHttpMigrateApplication = HttpMigration.application(openapi); typia.assert(migrate); } }; diff --git a/test/features/test_document_convert_v20.ts b/test/features/openapi/test_document_convert_v20.ts similarity index 89% rename from test/features/test_document_convert_v20.ts rename to test/features/openapi/test_document_convert_v20.ts index 1002ad7..526bb3a 100644 --- a/test/features/test_document_convert_v20.ts +++ b/test/features/openapi/test_document_convert_v20.ts @@ -3,7 +3,7 @@ import fs from "fs"; import typia from "typia"; export const test_document_convert_v20 = async (): Promise => { - const path: string = `${__dirname}/../../../examples/v2.0`; + const path: string = `${__dirname}/../../../../examples/v2.0`; for (const file of await fs.promises.readdir(path)) { if (file.endsWith(".json") === false) continue; const swagger: SwaggerV2.IDocument = typia.assert( diff --git a/test/features/test_document_convert_v30.ts b/test/features/openapi/test_document_convert_v30.ts similarity index 89% rename from test/features/test_document_convert_v30.ts rename to test/features/openapi/test_document_convert_v30.ts index a6fd498..ec91471 100644 --- a/test/features/test_document_convert_v30.ts +++ b/test/features/openapi/test_document_convert_v30.ts @@ -3,7 +3,7 @@ import fs from "fs"; import typia from "typia"; export const test_document_convert_v30 = async (): Promise => { - const path: string = `${__dirname}/../../../examples/v3.0`; + const path: string = `${__dirname}/../../../../examples/v3.0`; for (const file of await fs.promises.readdir(path)) { if (file.endsWith(".json") === false) continue; const swagger: OpenApiV3.IDocument = typia.assert( diff --git a/test/features/test_document_convert_v31.ts b/test/features/openapi/test_document_convert_v31.ts similarity index 89% rename from test/features/test_document_convert_v31.ts rename to test/features/openapi/test_document_convert_v31.ts index a22419a..4a42a2d 100644 --- a/test/features/test_document_convert_v31.ts +++ b/test/features/openapi/test_document_convert_v31.ts @@ -3,7 +3,7 @@ import fs from "fs"; import typia from "typia"; export const test_document_convert_v31 = async (): Promise => { - const path: string = `${__dirname}/../../../examples/v3.1`; + const path: string = `${__dirname}/../../../../examples/v3.1`; for (const file of await fs.promises.readdir(path)) { if (file.endsWith(".json") === false) continue; const swagger: OpenApiV3_1.IDocument = typia.assert( diff --git a/test/features/test_document_downgrade_v20.ts b/test/features/openapi/test_document_downgrade_v20.ts similarity index 94% rename from test/features/test_document_downgrade_v20.ts rename to test/features/openapi/test_document_downgrade_v20.ts index beef3a4..701bb0c 100644 --- a/test/features/test_document_downgrade_v20.ts +++ b/test/features/openapi/test_document_downgrade_v20.ts @@ -3,7 +3,7 @@ import fs from "fs"; import typia from "typia"; export const test_document_downgrade_v20 = async (): Promise => { - const path: string = `${__dirname}/../../../examples/v3.1`; + const path: string = `${__dirname}/../../../../examples/v3.1`; for (const directory of await fs.promises.readdir(path)) { const stats: fs.Stats = await fs.promises.lstat(`${path}/${directory}`); if (stats.isDirectory() === false) continue; diff --git a/test/features/test_document_downgrade_v30.ts b/test/features/openapi/test_document_downgrade_v30.ts similarity index 94% rename from test/features/test_document_downgrade_v30.ts rename to test/features/openapi/test_document_downgrade_v30.ts index b5d078e..90dd404 100644 --- a/test/features/test_document_downgrade_v30.ts +++ b/test/features/openapi/test_document_downgrade_v30.ts @@ -3,7 +3,7 @@ import fs from "fs"; import typia from "typia"; export const test_document_downgrade_v30 = async (): Promise => { - const path: string = `${__dirname}/../../../examples/v3.1`; + const path: string = `${__dirname}/../../../../examples/v3.1`; for (const directory of await fs.promises.readdir(path)) { const stats: fs.Stats = await fs.promises.lstat(`${path}/${directory}`); if (stats.isDirectory() === false) continue; diff --git a/test/features/test_json_schema_downgrade_v20.ts b/test/features/openapi/test_json_schema_downgrade_v20.ts similarity index 87% rename from test/features/test_json_schema_downgrade_v20.ts rename to test/features/openapi/test_json_schema_downgrade_v20.ts index da70757..4002f4a 100644 --- a/test/features/test_json_schema_downgrade_v20.ts +++ b/test/features/openapi/test_json_schema_downgrade_v20.ts @@ -1,6 +1,6 @@ import { TestValidator } from "@nestia/e2e"; import { OpenApi, SwaggerV2 } from "@samchon/openapi"; -import { SwaggerV2Downgrader } from "@samchon/openapi/lib/internal/SwaggerV2Downgrader"; +import { SwaggerV2Downgrader } from "@samchon/openapi/lib/converters/SwaggerV2Downgrader"; export const test_json_schema_downgrade_v20 = () => { const schema: OpenApi.IJsonSchema = { diff --git a/test/features/test_json_schema_downgrade_v30.ts b/test/features/openapi/test_json_schema_downgrade_v30.ts similarity index 87% rename from test/features/test_json_schema_downgrade_v30.ts rename to test/features/openapi/test_json_schema_downgrade_v30.ts index 51bf90c..c93af16 100644 --- a/test/features/test_json_schema_downgrade_v30.ts +++ b/test/features/openapi/test_json_schema_downgrade_v30.ts @@ -1,6 +1,6 @@ import { TestValidator } from "@nestia/e2e"; import { OpenApi, OpenApiV3 } from "@samchon/openapi"; -import { OpenApiV3Downgrader } from "@samchon/openapi/lib/internal/OpenApiV3Downgrader"; +import { OpenApiV3Downgrader } from "@samchon/openapi/lib/converters/OpenApiV3Downgrader"; export const test_json_schema_downgrade_v30 = () => { const schema: OpenApi.IJsonSchema = { diff --git a/test/features/test_json_schema_type_checker_cover_any.ts b/test/features/openapi/test_json_schema_type_checker_cover_any.ts similarity index 100% rename from test/features/test_json_schema_type_checker_cover_any.ts rename to test/features/openapi/test_json_schema_type_checker_cover_any.ts diff --git a/test/features/test_json_schema_type_checker_cover_array.ts b/test/features/openapi/test_json_schema_type_checker_cover_array.ts similarity index 100% rename from test/features/test_json_schema_type_checker_cover_array.ts rename to test/features/openapi/test_json_schema_type_checker_cover_array.ts diff --git a/test/features/test_json_schema_type_checker_cover_nullable.ts b/test/features/openapi/test_json_schema_type_checker_cover_nullable.ts similarity index 100% rename from test/features/test_json_schema_type_checker_cover_nullable.ts rename to test/features/openapi/test_json_schema_type_checker_cover_nullable.ts diff --git a/test/features/test_json_schema_type_checker_cover_number.ts b/test/features/openapi/test_json_schema_type_checker_cover_number.ts similarity index 100% rename from test/features/test_json_schema_type_checker_cover_number.ts rename to test/features/openapi/test_json_schema_type_checker_cover_number.ts diff --git a/test/features/test_json_schema_type_checker_cover_object.ts b/test/features/openapi/test_json_schema_type_checker_cover_object.ts similarity index 100% rename from test/features/test_json_schema_type_checker_cover_object.ts rename to test/features/openapi/test_json_schema_type_checker_cover_object.ts diff --git a/test/features/test_json_schema_type_checker_cover_string.ts b/test/features/openapi/test_json_schema_type_checker_cover_string.ts similarity index 100% rename from test/features/test_json_schema_type_checker_cover_string.ts rename to test/features/openapi/test_json_schema_type_checker_cover_string.ts diff --git a/test/index.ts b/test/index.ts index 0416acb..3f8ae72 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,12 +1,28 @@ import { DynamicExecutor } from "@nestia/e2e"; +import { NestFactory } from "@nestjs/core"; import chalk from "chalk"; +import { AppFilter } from "./controllers/AppFilter"; +import { AppModule } from "./controllers/AppModule"; + +const EXTENSION = __filename.substr(-2); +if (EXTENSION === "js") require("source-map-support").install(); + const main = async (): Promise => { + // PREPARE SERVER + const app = await NestFactory.create(AppModule, { logger: false }); + app.useGlobalFilters(new AppFilter(app.getHttpAdapter())); + await app.listen(3_000); + // DO TEST const report: DynamicExecutor.IReport = await DynamicExecutor.validate({ prefix: "test_", location: __dirname + "/features", - parameters: () => [], + parameters: () => [ + { + host: `http://localhost:3000`, + }, + ], onComplete: (exec) => { const trace = (str: string) => console.log(` - ${chalk.green(exec.name)}: ${str}`); @@ -31,9 +47,7 @@ const main = async (): Promise => { console.log("Failed"); console.log("Elapsed time", report.time.toLocaleString(), `ms`); } + await app.close(); if (exceptions.length) process.exit(-1); }; -main().catch((exp) => { - console.error(exp); - process.exit(-1); -}); +main().catch(console.error); diff --git a/test/nestia.config.ts b/test/nestia.config.ts new file mode 100644 index 0000000..7d06443 --- /dev/null +++ b/test/nestia.config.ts @@ -0,0 +1,13 @@ +import { INestiaConfig } from "@nestia/sdk"; +import { NestFactory } from "@nestjs/core"; + +import { AppModule } from "./controllers/AppModule"; + +export const NESTIA_CONFIG: INestiaConfig = { + input: () => NestFactory.create(AppModule), + swagger: { + output: "./swagger.json", + beautify: true, + }, +}; +export default NESTIA_CONFIG; diff --git a/test/swagger.json b/test/swagger.json new file mode 100644 index 0000000..efdd7be --- /dev/null +++ b/test/swagger.json @@ -0,0 +1,436 @@ +{ + "openapi": "3.1.0", + "servers": [ + { + "url": "https://github.com/samchon/nestia", + "description": "insert your server url" + } + ], + "info": { + "version": "0.5.0-dev.20240824", + "title": "@samchon/openapi", + "description": "OpenAPI definitions and converters for 'typia' and 'nestia'.", + "license": { + "name": "MIT" + } + }, + "paths": { + "/{index}/{level}/{optimal}/parameters": { + "get": { + "tags": [], + "parameters": [ + { + "name": "index", + "in": "path", + "schema": { + "type": "string", + "format": "uri", + "contentMediaType": "text/html" + }, + "required": true + }, + { + "name": "level", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "optimal", + "in": "path", + "schema": { + "type": "boolean" + }, + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + } + } + } + }, + "/{index}/{level}/{optimal}/query": { + "get": { + "tags": [], + "parameters": [ + { + "name": "index", + "in": "path", + "schema": { + "type": "string", + "format": "uri", + "contentMediaType": "text/html" + }, + "required": true + }, + { + "name": "level", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "optimal", + "in": "path", + "schema": { + "type": "boolean" + }, + "required": true + }, + { + "name": "summary", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "thumbnail", + "in": "query", + "schema": { + "type": "string", + "format": "uri", + "contentMediaType": "image/*" + }, + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + } + } + } + }, + "/{index}/{level}/{optimal}/body": { + "post": { + "tags": [], + "parameters": [ + { + "name": "index", + "in": "path", + "schema": { + "type": "string", + "format": "uri", + "contentMediaType": "text/html" + }, + "required": true + }, + { + "name": "level", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "optimal", + "in": "path", + "schema": { + "type": "boolean" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + } + } + } + }, + "/{index}/{level}/{optimal}/query/body": { + "post": { + "tags": [], + "parameters": [ + { + "name": "index", + "in": "path", + "schema": { + "type": "string", + "format": "uri", + "contentMediaType": "text/html" + }, + "required": true + }, + { + "name": "level", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "optimal", + "in": "path", + "schema": { + "type": "boolean" + }, + "required": true + }, + { + "name": "thumbnail", + "in": "query", + "schema": { + "type": "string", + "format": "uri", + "contentMediaType": "image/*" + }, + "required": true + }, + { + "name": "summary", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "summary": { + "type": "string" + } + }, + "required": [ + "summary" + ] + } + }, + "required": [ + "query" + ] + } + } + } + } + } + } + }, + "/{index}/{level}/{optimal}/multipart": { + "post": { + "tags": [], + "parameters": [ + { + "name": "index", + "in": "path", + "schema": { + "type": "string", + "format": "uri", + "contentMediaType": "text/html" + }, + "required": true + }, + { + "name": "level", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "optimal", + "in": "path", + "schema": { + "type": "boolean" + }, + "required": true + }, + { + "name": "summary", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "thumbnail", + "in": "query", + "schema": { + "type": "string", + "format": "uri", + "contentMediaType": "image/*" + }, + "required": true + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/IMultipart" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "body": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "draft": { + "type": "boolean" + } + }, + "required": [ + "file", + "title", + "body", + "draft" + ] + } + }, + "required": [ + "body" + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "IQuery": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "thumbnail": { + "type": "string", + "format": "uri", + "contentMediaType": "image/*" + } + }, + "required": [ + "summary", + "thumbnail" + ] + }, + "IBody": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "draft": { + "type": "boolean" + } + }, + "required": [ + "title", + "body", + "draft" + ] + }, + "IMultipart": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "draft": { + "type": "boolean" + } + }, + "required": [ + "file", + "title", + "body", + "draft" + ] + } + } + }, + "tags": [], + "x-samchon-emended": true +} \ No newline at end of file diff --git a/test/tsconfig.json b/test/tsconfig.json index 753ac46..1bfade9 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,14 +1,19 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "target": "ESNext", "outDir": "../bin", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "resolveJsonModule": true, "paths": { "@samchon/openapi": ["../src/index.ts"], "@samchon/openapi/lib/*": ["../src/*"], }, "plugins": [ - { "transform": "typia/lib/transform" }, { "transform": "typescript-transform-paths" }, + { "transform": "typia/lib/transform" }, + { "transform": "@nestia/core/lib/transform" }, ] }, "include": ["../src", "../test"] diff --git a/tsconfig.json b/tsconfig.json index 5711531..c21c8ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "ES5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "lib": [ "DOM", "ES2020", @@ -63,7 +63,7 @@ // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ @@ -94,8 +94,8 @@ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */