From ee626cf871ba4edf6917e2682bae725d277c6b04 Mon Sep 17 00:00:00 2001 From: Michael Vandeberg Date: Mon, 27 Jan 2025 15:34:08 -0700 Subject: [PATCH 1/3] #28: Add API route for returning contract swagger and add page to render Swagger component --- app/swagger/[contract_id]/page.tsx | 23 ++ app/swagger/[contract_id]/react-swagger.tsx | 14 ++ .../contract/[contract_id]/swagger/route.ts | 27 ++ swagger.specs.json | 12 - utils/swagger.ts | 232 ++++++++++++++++++ 5 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 app/swagger/[contract_id]/page.tsx create mode 100644 app/swagger/[contract_id]/react-swagger.tsx create mode 100644 app/v1/contract/[contract_id]/swagger/route.ts create mode 100644 utils/swagger.ts diff --git a/app/swagger/[contract_id]/page.tsx b/app/swagger/[contract_id]/page.tsx new file mode 100644 index 0000000..a3aed89 --- /dev/null +++ b/app/swagger/[contract_id]/page.tsx @@ -0,0 +1,23 @@ +import { createSwaggerSpec } from 'next-swagger-doc' +import ReactSwagger from './react-swagger' +import 'swagger-ui-react/swagger-ui.css' +import {getContractSwagger} from '@/utils/swagger' + +const getApiDocs = async (contract_id: string) => { + const spec = createSwaggerSpec({definition: await getContractSwagger(contract_id)}) + return spec +} + +export default async function IndexPage({ + params, +}: { + params: Promise<{ contract_id: string }> +}) +{ + const spec = await getApiDocs((await params).contract_id) + return ( +
+ +
+ ) +} diff --git a/app/swagger/[contract_id]/react-swagger.tsx b/app/swagger/[contract_id]/react-swagger.tsx new file mode 100644 index 0000000..b6d52a2 --- /dev/null +++ b/app/swagger/[contract_id]/react-swagger.tsx @@ -0,0 +1,14 @@ +'use client' + +import SwaggerUI from 'swagger-ui-react' +import 'swagger-ui-react/swagger-ui.css' + +type Props = { + spec: Record +} + +function ReactSwagger({ spec }: Props) { + return +} + +export default ReactSwagger diff --git a/app/v1/contract/[contract_id]/swagger/route.ts b/app/v1/contract/[contract_id]/swagger/route.ts new file mode 100644 index 0000000..ce9b7aa --- /dev/null +++ b/app/v1/contract/[contract_id]/swagger/route.ts @@ -0,0 +1,27 @@ +import { AppError, handleError } from '@/utils/errors' +import { getContractSwagger } from '@/utils/swagger' + +/** + * @swagger + * /v1/contract/{contract_id}/abi: + * get: + * tags: [Contracts] + * description: Returns a Swagger file for the Contract's methods + * summary: Returns a Swagger file for the Contract, detailing its methods. + * parameters: + * - name: contract_id + * schema: + * type: string + * in: path + * description: Koinos address of the contract, name of the contract (for system contracts) or KAP name + * required: true + * example: 15DJN4a8SgrbGhhGksSBASiSYjGnMU8dGL + */ + +export async function GET(request: Request, { params }: { params: { contract_id: string } }) { + try { + return Response.json(await getContractSwagger(params.contract_id)) + } catch (error) { + return handleError(error as Error) + } +} diff --git a/swagger.specs.json b/swagger.specs.json index 745188a..a5c76bc 100644 --- a/swagger.specs.json +++ b/swagger.specs.json @@ -7,18 +7,6 @@ "title": "Koinos REST API", "version": "1.0.0" }, - "components": { - "parameters": { - "X-JSON-RPC-URL": { - "name": "X-JSON-RPC-URL", - "schema": { - "type": "string" - }, - "in": "header", - "description": "Override default JSON RPC URL used for querying the blockchain" - } - } - }, "tags": [ { "name": "Accounts", diff --git a/utils/swagger.ts b/utils/swagger.ts new file mode 100644 index 0000000..8c4fbb4 --- /dev/null +++ b/utils/swagger.ts @@ -0,0 +1,232 @@ +import { getContract, getContractId } from '@/utils/contracts' +import { AppError, getErrorMessage } from '@/utils/errors' + +function findTypeInTypes(typename: string, types: any) : any { + if (types[typename]) + return types[typename] + else if (types.nested) + return findTypeInTypes(typename, types.nested) + + return undefined +} + +function findTypeFromABI(typename: string, abi: any) : any { + if (typename[0] == '.') + typename = typename.substring(1); + + const namespaceTokens = typename.split('.') + + if (namespaceTokens.length > 1) { + let typeDef = abi.koilib_types + + for (const token of namespaceTokens) { + typeDef = findTypeInTypes(token, typeDef) + + if (!typeDef) + return typeDef + } + + return typeDef + } + + return findTypeInTypes(typename, abi.koilib_types) +} + + +function protoTypeToSwaggerType(type: any, abi: any) : any { + let result : any = {}; + + for (const [key, value] of (Object.entries(type.fields) as [any])) { + let swaggerType: any = {} + + switch (value.type) { + case "int32": + case "uint32": + case "sint32": + case "fixed32": + case "sfixed32": + { + swaggerType.type = "integer"; + swaggerType.format = "int32"; + break; + } + case "int64": + case "uint64": + case "sint64": + case "fixed64": + case "sfixed64": + { + swaggerType.type = "string"; + break; + } + case "bool": + { + swaggerType.type = "boolean"; + break; + } + case "string": + case "bytes": + { + swaggerType.type = "string"; + break; + } + case "double": + case "float": + throw new AppError("Koinos does not support floating points in smart contracts"); + default: + { + swaggerType.type = "object" + + const fieldType = findTypeFromABI(value.type, abi) + + if (fieldType) + swaggerType.properties = protoTypeToSwaggerType(fieldType, abi); + + break; + } + } + + if (value.rule && value.rule == "repeated") { + swaggerType = { + type: "array", + items: swaggerType + } + } + + result[key] = swaggerType; + } + + return result; +} + +export async function getContractSwagger(contract_id: string) : Promise< any > +{ + try { + const contract_address = await getContractId(contract_id) + const contract = (await getContract(contract_address) as any) + + let result = { + openapi: "3.0.0", + info: { + title: `'${contract_id}' REST API`, + version: "1.0.0", + }, + tags: [ + { + name: "Contracts", + description: `Includes endpoints for interacting with the '${contract_id}' contract on Koinos`, + } + ], + paths: {} + } + + for (const [key, value] of (Object.entries(contract.abi.methods) as [any])) + { + const description = (value as any).read_only ? + `Call '${key}' and return the result.` : + `Returns the operation for calling '${key}'.`; + + (result.paths as any)[`/v1/contract/${contract_id}/${key}`] = { + get : { + tags: ["Contracts"], + description, + parameters: [] + }, + post: { + tags: ["Contracts"], + description + } + } + + let method = (result.paths as any)[`/v1/contract/${contract_id}/${key}`]; + + const argsType = findTypeFromABI(value.argument, contract.abi); + + if (!argsType.fields) + continue; + + const swaggerType = protoTypeToSwaggerType(argsType, contract.abi); + + for (const [fieldName, fieldType] of (Object.entries(swaggerType))) { + method.get.parameters.push({ + name: fieldName, + in: "query", + schema: fieldType + }); + } + + method.post.requestBody = { + description: `Arguments for the method '${key}'`, + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: swaggerType, + } + } + } + } + + let responses : any; + + if (!(value as any).read_only) + { + responses = { + "200": { + description: `Operation for calling '${key}'`, + content: { + "application/json": { + schema: { + type: "object", + properties: { + call_contract: { + type: "object", + properties: { + contract_id: { + type: "string" + }, + entry_point: { + type: "integer" + }, + args: { + type: "string" + } + } + } + } + } + } + } + } + } + } + else + { + const retType = findTypeFromABI(value.return, contract.abi); + const swaggerType = protoTypeToSwaggerType(retType, contract.abi); + + responses = { + "200": { + description: `Result of calling '${key}'`, + content: { + "application/json": { + schema: { + type: "object", + properties: swaggerType + } + } + } + } + } + } + + method.get.responses = responses + method.post.responses = responses + } + + return result + } catch (error) { + throw new AppError(getErrorMessage(error as Error)) + } +} \ No newline at end of file From a220d4ecc90a17d48ed65be31b6ad11484101995 Mon Sep 17 00:00:00 2001 From: Michael Vandeberg Date: Wed, 29 Jan 2025 12:30:24 -0700 Subject: [PATCH 2/3] #28: Fix nicknames in /swagger pages, fix ABI route to returned the 'fixed' ABI, and handle enums in proto types --- app/swagger/[contract_id]/page.tsx | 28 +++++++++---- app/v1/contract/[contract_id]/abi/route.ts | 46 ++-------------------- utils/swagger.ts | 15 +++++-- 3 files changed, 34 insertions(+), 55 deletions(-) diff --git a/app/swagger/[contract_id]/page.tsx b/app/swagger/[contract_id]/page.tsx index a3aed89..4b08b18 100644 --- a/app/swagger/[contract_id]/page.tsx +++ b/app/swagger/[contract_id]/page.tsx @@ -4,8 +4,7 @@ import 'swagger-ui-react/swagger-ui.css' import {getContractSwagger} from '@/utils/swagger' const getApiDocs = async (contract_id: string) => { - const spec = createSwaggerSpec({definition: await getContractSwagger(contract_id)}) - return spec + return createSwaggerSpec({definition: await getContractSwagger(contract_id)}) } export default async function IndexPage({ @@ -14,10 +13,23 @@ export default async function IndexPage({ params: Promise<{ contract_id: string }> }) { - const spec = await getApiDocs((await params).contract_id) - return ( -
- -
- ) + const contract_id = (await params).contract_id.replace("%40", "@"); + + try { + const spec = await getApiDocs(contract_id) + return ( +
+ +
+ ) + } + catch (error) { + return ( +
+

{'An error occurred'}

+

{`The contract '${contract_id}' either does not exist or has an invalid ABI.`}

+

{`${error}`}

+
+ ) + } } diff --git a/app/v1/contract/[contract_id]/abi/route.ts b/app/v1/contract/[contract_id]/abi/route.ts index 3fd1d9d..9b04f16 100644 --- a/app/v1/contract/[contract_id]/abi/route.ts +++ b/app/v1/contract/[contract_id]/abi/route.ts @@ -1,9 +1,7 @@ -import { getContractId } from '@/utils/contracts' +import { fixAbi, getContractId } from '@/utils/contracts' import { AppError, handleError } from '@/utils/errors' import { getProvider } from '@/utils/providers' import { Abi } from 'koilib' -import { convert } from '@roamin/koinos-pb-to-proto' -import protobufjs from 'protobufjs' /** * @swagger @@ -70,49 +68,11 @@ export async function GET(request: Request, { params }: { params: { contract_id: throw new AppError(`abi not available for contract ${contract_id}`) } - const abi: Abi = { + let abi: Abi = { ...JSON.parse(response.meta.abi) } - Object.keys(abi.methods).forEach((name) => { - abi!.methods[name] = { - ...abi!.methods[name] - } - - //@ts-ignore this is needed to be compatible with "old" abis - if (abi.methods[name]['entry-point']) { - //@ts-ignore this is needed to be compatible with "old" abis - abi.methods[name].entry_point = parseInt( - //@ts-ignore this is needed to be compatible with "old" abis - String(abi.methods[name]['entry-point']) - ) - } - - //@ts-ignore this is needed to be compatible with "old" abis - if (abi.methods[name]['read-only'] !== undefined) { - //@ts-ignore this is needed to be compatible with "old" abis - abi.methods[name].read_only = abi.methods[name]['read-only'] - } - }) - - if (abi.types) { - const pd = convert(abi?.types) - if (pd.length) { - try { - const root = new protobufjs.Root() - for (const desc of pd) { - const parserResult = protobufjs.parse(desc.definition, { - keepCase: true - }) - root.add(parserResult.root) - } - // extract the first nested object - abi.koilib_types = root.toJSON().nested?.[''] - } catch (error) { - // ignore the parsing errors - } - } - } + abi = fixAbi(abi) return Response.json({ contract_id, ...response.meta, abi }) } catch (error) { diff --git a/utils/swagger.ts b/utils/swagger.ts index 8c4fbb4..e753ad0 100644 --- a/utils/swagger.ts +++ b/utils/swagger.ts @@ -75,12 +75,19 @@ function protoTypeToSwaggerType(type: any, abi: any) : any { throw new AppError("Koinos does not support floating points in smart contracts"); default: { - swaggerType.type = "object" - const fieldType = findTypeFromABI(value.type, abi) - if (fieldType) + if (fieldType && fieldType.properties) + { + // Object case swaggerType.properties = protoTypeToSwaggerType(fieldType, abi); + swaggerType.type = "object" + } + else if (fieldType && fieldType.values) + { + // Enum case + swaggerType.type = "string" + } break; } @@ -142,7 +149,7 @@ export async function getContractSwagger(contract_id: string) : Promise< any > const argsType = findTypeFromABI(value.argument, contract.abi); - if (!argsType.fields) + if (!argsType || !argsType.fields) continue; const swaggerType = protoTypeToSwaggerType(argsType, contract.abi); From 0dca9db66755f45c6756b10d3a91b7da7f0dcc50 Mon Sep 17 00:00:00 2001 From: Michael Vandeberg Date: Wed, 29 Jan 2025 12:48:44 -0700 Subject: [PATCH 3/3] #28: Add proper swagger documentation for the swagger path --- app/v1/contract/[contract_id]/swagger/route.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/v1/contract/[contract_id]/swagger/route.ts b/app/v1/contract/[contract_id]/swagger/route.ts index ce9b7aa..e2b6595 100644 --- a/app/v1/contract/[contract_id]/swagger/route.ts +++ b/app/v1/contract/[contract_id]/swagger/route.ts @@ -3,7 +3,7 @@ import { getContractSwagger } from '@/utils/swagger' /** * @swagger - * /v1/contract/{contract_id}/abi: + * /v1/contract/{contract_id}/swagger: * get: * tags: [Contracts] * description: Returns a Swagger file for the Contract's methods @@ -16,6 +16,22 @@ import { getContractSwagger } from '@/utils/swagger' * description: Koinos address of the contract, name of the contract (for system contracts) or KAP name * required: true * example: 15DJN4a8SgrbGhhGksSBASiSYjGnMU8dGL + * responses: + * 200: + * description: Swagger + * content: + * application/json: + * schema: + * type: object + * example: + * openapi: 3.0.0 + * info: + * title: "'15DJN4a8SgrbGhhGksSBASiSYjGnMU8dGL' REST API" + * version: 1.0.0 + * tags: + * - name: Contracts + * description: Includes endpoints for interacting with the '15DJN4a8SgrbGhhGksSBASiSYjGnMU8dGL' contract on Koinos + * paths: {} */ export async function GET(request: Request, { params }: { params: { contract_id: string } }) {