diff --git a/openspec/changes/add-openai-codegen/proposal.md b/openspec/changes/add-openai-codegen/proposal.md new file mode 100644 index 00000000..98798e89 --- /dev/null +++ b/openspec/changes/add-openai-codegen/proposal.md @@ -0,0 +1,59 @@ +# Add OpenAPI → ABAP client codegen (`@abapify/openai-codegen`) + +## Why + +Consuming modern REST/JSON APIs (OpenAI, Petstore, etc.) from ABAP today requires hand-written, boilerplate-heavy clients. We want a **deterministic TypeScript-based generator** that reads an OpenAPI spec and emits a single zero-dependency ABAP class, 100% typed to the spec, that can be shipped into cloud-first SAP systems (BTP / Steampunk) through the existing `adt-cli` deploy flow — and tested against a live ABAP system in this monorepo's CI. + +A prior LLM-driven attempt (`abapify/codygen`) showed that non-deterministic generation is hard to maintain. This change picks the opposite approach: **typed AST + pretty-printer**, like `ts.factory` for ABAP. + +## What Changes + +New packages: + +- **`@abapify/abap-ast`** — zero-dependency typed AST nodes and pretty-printer for ABAP classes, interfaces, types, statements, and expressions. Foundation for deterministic code generation. No parser (yet); emission only. +- **`@abapify/openai-codegen`** — OpenAPI → ABAP class code generator. Uses `@apidevtools/swagger-parser` for `$ref` dereferencing + validation, then drives a typed planner → emitter pipeline that produces a single ABAP class per spec. Supports target profiles (`on-prem-classic`, `s4-onprem-modern`, `s4-cloud`) and output formats (`abapgit`, `gcts`). + +New sample: + +- **`samples/petstore3-client`** — end-to-end proof project. Vendored Petstore v3 OpenAPI spec, a `generate` script, committed generated ABAP (for both abapGit and gCTS layouts), smoke scripts for `adt abap run`, and an ABAP Unit test class. Deployed to the `TRL` ABAP Cloud system in CI. + +Scope of first version (`s4-cloud` target only, the others are designed-for but not emitted in v1): + +- Full JSON Schema 2020-12 subset: primitives, objects, arrays, enums, `$ref`, `allOf` (flatten/INCLUDE), `oneOf`/`anyOf` with discriminator, nullable. +- All Operation object fields: parameters (path/query/header/cookie with `style`/`explode`), `requestBody`, `responses` (2xx typed return / 4xx/5xx → generated `ZCX_…_ERROR`), `security`, `servers`, `tags`, `deprecated`. +- Security: `apiKey`, `http bearer`, `http basic`. `oauth2` / `openIdConnect` via overridable `ON_AUTHORIZE` hook. +- Inline JSON runtime (tokenizer + per-type serializer/deserializer) emitted into the generated class — no `/ui2/cl_json` or `xco_cp_json` runtime dependency, satisfying Steampunk's whitelist. + +Out of scope for v1 (tracked as follow-up): + +- `on-prem-classic` and `s4-onprem-modern` runtime emission (the profile definitions exist but aren't wired yet). +- OpenAPI webhooks, callbacks, and full OAuth2 flows. +- Streaming / SSE responses. +- `.aclass` DSL parser (will live under `@abapify/abap-ast` when it's added). + +## Affected packages + +- **new** `packages/abap-ast/` — AST + printer. +- **new** `packages/openai-codegen/` — generator + CLI. +- **new** `samples/petstore3-client/` — E2E fixture and deployment target. +- **modified** `adt.config.ts` — no change required (codegen is a bun CLI, not an `adt` subcommand). +- **touched** `AGENTS.md` — add new packages to the dependency graph section. + +## Architectural impact + +New leaf packages with no upstream dependencies inside the monorepo: + +``` +@abapify/abap-ast (zero deps) +@abapify/openai-codegen ─► @abapify/abap-ast +samples/petstore3-client ─► @abapify/openai-codegen (build-time only) +``` + +Cloud profile constraint drives one subtle design decision: the generated ABAP must only reference kernel classes on the Steampunk whitelist. `@apidevtools/swagger-parser` stays as a TS **dev / CLI-time** dependency — it never reaches ABAP output. + +## Testing & rollback + +- Unit: AST printer snapshots, JSON-Schema → ABAP type-mapping matrix, OpenAPI operation → method emission. +- Fixture: regenerate `samples/petstore3-client/generated/**/*.abap` and assert it matches committed output (`git diff --exit-code`). +- E2E: deploy the generated `ZCL_PETSTORE3_CLIENT` to `TRL` via `adt-cli`, run an `adt abap run` smoke snippet, and execute the generated ABAP Unit test class via `adt aunit`. +- Rollback: pure additive change. Deleting the three new directories restores previous behaviour; nothing else depends on them. diff --git a/openspec/changes/add-openai-codegen/specs/abap-ast/spec.md b/openspec/changes/add-openai-codegen/specs/abap-ast/spec.md new file mode 100644 index 00000000..b820ab92 --- /dev/null +++ b/openspec/changes/add-openai-codegen/specs/abap-ast/spec.md @@ -0,0 +1,31 @@ +# Delta — `abap-ast` capability (new) + +## ADDED Requirements + +### Requirement: Typed AST nodes for ABAP + +`@abapify/abap-ast` SHALL expose typed factory functions for constructing ABAP program structure (classes, interfaces, types, methods, parameters, data declarations, statements, expressions, comments) as a pure TypeScript value graph. + +#### Scenario: Building a class AST + +- **WHEN** `classDef({ name: 'ZCL_FOO', isFinal: true, sections: [...] })` is called +- **THEN** the returned value is a typed `ClassDef` node whose fields are statically checked by TypeScript and whose shape is documented by the exported type. + +### Requirement: Deterministic pretty-printing + +The package SHALL expose a `print(node): string` function whose output is stable across machines and runs: 2-space indent, keywords upper-cased, identifiers preserved, declarations ordered by declaration index (no implicit sorting), and no trailing whitespace. + +#### Scenario: Snapshot stability + +- **GIVEN** a fixed AST graph constructed from the same factory calls +- **WHEN** `print(ast)` is called on two different machines +- **THEN** both outputs are byte-identical. + +### Requirement: Zero runtime dependencies + +`@abapify/abap-ast` SHALL have zero runtime dependencies and be consumable from any TypeScript 5 ESM environment. + +#### Scenario: Installing standalone + +- **WHEN** the package is installed outside this monorepo into a vanilla `bun init` project +- **THEN** it resolves with no `dependencies` entries and exposes working `classDef` / `print` imports. diff --git a/openspec/changes/add-openai-codegen/specs/openai-codegen/spec.md b/openspec/changes/add-openai-codegen/specs/openai-codegen/spec.md new file mode 100644 index 00000000..038818c4 --- /dev/null +++ b/openspec/changes/add-openai-codegen/specs/openai-codegen/spec.md @@ -0,0 +1,62 @@ +# Delta — `openai-codegen` capability (new) + +## ADDED Requirements + +### Requirement: Deterministic OpenAPI → ABAP class generation + +The `@abapify/openai-codegen` CLI SHALL convert an OpenAPI 3.0.x / 3.1.x specification into a single ABAP class whose public interface maps 1:1 onto the spec's operations and schemas, such that regenerating from the same input always produces byte-identical output. + +#### Scenario: Generating the Petstore v3 client + +- **GIVEN** the vendored `samples/petstore3-client/spec/openapi.json` +- **WHEN** `openai-codegen --input ./spec/openapi.json --out ./generated/abapgit --target s4-cloud --format abapgit --class-name ZCL_PETSTORE3_CLIENT --type-prefix ZPS3_` is executed +- **THEN** an abapGit-layout directory is written containing `ZCL_PETSTORE3_CLIENT.clas.abap`, `ZCL_PETSTORE3_CLIENT.clas.xml`, and package manifest files, and re-running the same command leaves `git diff` empty. + +#### Scenario: Every schema becomes a typed ABAP TYPES entry + +- **GIVEN** an OpenAPI spec with `components.schemas.Pet` referencing `Category` and `Tag` +- **WHEN** the generator runs +- **THEN** the emitted class contains a `TYPES: BEGIN OF ty_ps3_pet ... END OF ty_ps3_pet.` structure whose fields have ABAP types derived from `Category` and `Tag` (themselves emitted as TYPES), and topological ordering ensures referenced types appear before references. + +#### Scenario: Every operation becomes an ABAP method + +- **GIVEN** operation `findPetsByStatus` with a `query` parameter and a 200 response returning `array of Pet` +- **WHEN** the generator runs +- **THEN** the emitted class has method `FIND_PETS_BY_STATUS` with `IMPORTING iv_status TYPE string` and `RETURNING VALUE(rt_pets) TYPE `, and the body serializes the query parameter per its `style`/`explode`, sends the HTTP request, and deserializes the JSON response into `rt_pets`. + +### Requirement: Zero runtime dependencies in generated ABAP + +The generated ABAP class targeting `s4-cloud` SHALL only reference system classes on the Steampunk-cloud whitelist (`cl_web_http_client_manager`, `cl_http_destination_provider`, `cl_http_utility`, `cl_system_uuid`, `cl_abap_char_utilities`), with no dependency on `/ui2/cl_json` or any non-kernel class. + +#### Scenario: JSON without /ui2/cl_json + +- **WHEN** the generator targets `s4-cloud` +- **THEN** the emitted class contains inline private methods for JSON tokenization and per-type serialization/deserialization, and the class does not reference `/ui2/cl_json` or `xco_cp_json` in its source. + +#### Scenario: Whitelist enforcement + +- **WHEN** the emitter is asked to reference a class that is not on the active profile's whitelist +- **THEN** the generator fails with a descriptive error naming the class and the active profile. + +### Requirement: Output format abstraction + +The generator SHALL package the emitted class as either an abapGit directory layout or a gCTS payload, selectable via `--format`. + +#### Scenario: abapGit layout + +- **WHEN** `--format abapgit` is passed +- **THEN** the output directory contains `.clas.abap`, `.clas.xml`, and a `package.devc.xml` in conventional abapGit structure, suitable for `adt-plugin-abapgit` import. + +#### Scenario: gCTS layout + +- **WHEN** `--format gcts` is passed +- **THEN** the output directory contains the class artefacts in the gCTS-expected structure, suitable for `adt-plugin-gcts` import. + +### Requirement: Target-profile class whitelist + +Each target profile SHALL declare the set of kernel classes its emitted ABAP may reference, and the generator SHALL refuse to emit references outside that set. + +#### Scenario: Cloud profile whitelist + +- **GIVEN** `--target s4-cloud` +- **THEN** the emitter restricts HTTP to `cl_web_http_client_manager` / `cl_http_destination_provider` and rejects any attempt to emit `cl_http_client=>create_by_destination`. diff --git a/openspec/changes/add-openai-codegen/tasks.md b/openspec/changes/add-openai-codegen/tasks.md new file mode 100644 index 00000000..7d4695da --- /dev/null +++ b/openspec/changes/add-openai-codegen/tasks.md @@ -0,0 +1,48 @@ +# Tasks — `add-openai-codegen` + +Waves reflect subagent parallelism. Each task lists the package(s) it touches so waves don't collide. + +## Wave 0 — scaffold (sequential, done by lead) + +- [x] Create `packages/abap-ast` skeleton (package.json, tsconfig, tsdown, vitest, eslint, `src/index.ts`). +- [x] Create `packages/openai-codegen` skeleton (same template + CLI entry). +- [x] Create `samples/petstore3-client` with vendored `spec/openapi.json`. +- [x] Install workspace deps (`bun install`), verify `bunx nx build` succeeds on empty packages. +- [x] Create this OpenSpec change document. +- [x] Feature branch `feat/openai-codegen-abap` off `main`. + +## Wave 1 — foundations (parallel subagents) + +- [ ] **abap-ast #1** — node types + factories: `ClassDef`, `InterfaceDef`, `TypeDef`, `TypeRef`, `Section`, `MethodDef`, `MethodParam`, `DataDecl`, `Statement` (assignment / call / raise / if / loop / try / return), `Expr` (literal / identifier / call / binop), `Comment`. Package `packages/abap-ast/src/nodes/`. +- [ ] **openai-codegen #1** — OAS loader & normalizer: wrap `@apidevtools/swagger-parser`, dereference, hoist path-level parameters into operations, collect 2xx / 4xx-5xx response buckets, normalize media types. `packages/openai-codegen/src/oas/`. +- [ ] **openai-codegen #2** — target profiles & class whitelist for `s4-cloud` only (others are type stubs with `TODO`). `packages/openai-codegen/src/profiles/`. + +## Wave 2 — emitters (parallel, depend on Wave 1) + +- [ ] **abap-ast #2** — pretty-printer + snapshot tests for every node kind in `tests/printer.test.ts`. Deterministic: 2-space indent, UPPER keywords, stable ordering within sections. +- [ ] **openai-codegen #3** — type planner + emitter: JSON Schema → `TypeDef` AST (primitives, array, object, enum, `$ref`, `allOf` flatten, `oneOf`/`anyOf` with discriminator, nullable-as-flag). Includes stable-id allocator (≤30 chars + hash suffix). Tests: mapping matrix per schema shape. +- [ ] **openai-codegen #4** — inline JSON runtime: ABAP fragments (tokenizer + type-specific ser/de) emitted into the class for `s4-cloud`. Stored as plain `.abap` templates under `src/runtime/s4-cloud/` and assembled by the emitter. + +## Wave 3 — integration (parallel) + +- [ ] **openai-codegen #5** — operation emitter: operationId → `MethodDef`; parameter serialization per `style`/`explode`; request body; response routing (typed 2xx return, `RAISING ZCX_…_ERROR` for 4xx/5xx); local exception class. +- [ ] **openai-codegen #6** — security schemes: `apiKey`, `http bearer`, `http basic` auto-injection; constructor wiring; OAuth2 `ON_AUTHORIZE` hook stub. +- [ ] **openai-codegen #7** — CLI (`commander`): `openai-codegen --input --out --target --format --class-name --type-prefix`; deterministic stdout; non-zero exit on spec validation errors. +- [ ] **openai-codegen #8** — format plugins: emit abapGit layout (`.clas.abap` + `.clas.xml` + devc manifest) and gCTS layout. No new runtime deps required — only TS emission of package layouts. + +## Wave 4 — E2E on live TRL (sequential, single agent) + +- [ ] Generate `samples/petstore3-client/generated/{abapgit,gcts}`; commit results. +- [ ] `bunx adt deploy samples/petstore3-client/generated/abapgit --package $TMP --system TRL`. +- [ ] Smoke: `bunx adt abap run samples/petstore3-client/e2e/smoke.abap` — instantiate the generated client, call `GET /pet/findByStatus`, print first pet name. +- [ ] Deploy ABAP Unit test class `ZCL_PETSTORE3_CLIENT_TESTS` (hand-written, hits a live path and asserts shape). +- [ ] `bunx adt aunit zcl_petstore3_client_tests` — must pass with 0 failures. +- [ ] Regression: `bunx nx test abap-ast openai-codegen` green; `git diff samples/petstore3-client/generated/` empty after re-running `bun run generate`. + +## Wave 5 — verification & PR + +- [ ] `bunx nx run-many -t build,test,typecheck,lint -p abap-ast,openai-codegen` +- [ ] `bunx nx format:write` +- [ ] Update root `AGENTS.md` dependency graph. +- [ ] `AGENTS.md` per package (abap-ast, openai-codegen) with conventions. +- [ ] Commit waves as separate commits; push feature branch; open PR with summary + test plan + generated-artifact diff stats. diff --git a/packages/abap-ast/eslint.config.js b/packages/abap-ast/eslint.config.js new file mode 100644 index 00000000..b7f62772 --- /dev/null +++ b/packages/abap-ast/eslint.config.js @@ -0,0 +1,3 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [...baseConfig]; diff --git a/packages/abap-ast/package.json b/packages/abap-ast/package.json new file mode 100644 index 00000000..94022756 --- /dev/null +++ b/packages/abap-ast/package.json @@ -0,0 +1,27 @@ +{ + "name": "@abapify/abap-ast", + "publishConfig": { + "access": "public" + }, + "version": "0.1.0", + "description": "Zero-dependency typed AST and pretty-printer for ABAP source code (classes, interfaces, types, statements)", + "type": "module", + "types": "./dist/index.d.mts", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "dependencies": {}, + "files": [ + "dist", + "README.md" + ], + "keywords": [ + "abap", + "ast", + "codegen", + "sap" + ], + "author": "abapify", + "license": "MIT" +} diff --git a/packages/abap-ast/src/index.ts b/packages/abap-ast/src/index.ts new file mode 100644 index 00000000..c645543e --- /dev/null +++ b/packages/abap-ast/src/index.ts @@ -0,0 +1,2 @@ +export * from './nodes'; +export * from './printer'; diff --git a/packages/abap-ast/src/nodes/base.ts b/packages/abap-ast/src/nodes/base.ts new file mode 100644 index 00000000..0561698f --- /dev/null +++ b/packages/abap-ast/src/nodes/base.ts @@ -0,0 +1,83 @@ +import { AbapAstError } from './errors'; + +/** All node kinds in the AST. */ +export type NodeKind = + // types + | 'BuiltinType' + | 'TableType' + | 'StructureType' + | 'NamedTypeRef' + | 'EnumType' + | 'TypeDef' + // data + | 'DataDecl' + | 'ConstantDecl' + | 'FieldSymbolDecl' + // statements + | 'Assign' + | 'Call' + | 'Raise' + | 'If' + | 'Loop' + | 'Return' + | 'Try' + | 'Append' + | 'Insert' + | 'Read' + | 'Clear' + | 'Exit' + | 'Continue' + | 'Raw' + // expressions + | 'Literal' + | 'IdentifierExpr' + | 'ConstructorExpr' + | 'MethodCallExpr' + | 'BinOp' + | 'StringTemplate' + | 'Cast' + // members + | 'MethodParam' + | 'MethodDef' + | 'MethodImpl' + | 'EventDef' + | 'AttributeDef' + // class / interface + | 'Section' + | 'ClassDef' + | 'LocalClassDef' + | 'InterfaceDef' + // shared + | 'Comment'; + +/** Base shape for any AST node. */ +export interface AbapNode { + readonly kind: NodeKind; +} + +/** An ABAP identifier (not validated beyond presence). */ +export type Identifier = string; + +/** An ABAP comment. Star comments begin at column 1; line comments use `"`. */ +export interface Comment extends AbapNode { + readonly kind: 'Comment'; + readonly text: string; + readonly style: 'star' | 'line'; +} + +export function comment(input: { + text: string; + style?: 'star' | 'line'; +}): Comment { + if (typeof input.text !== 'string') { + throw new AbapAstError('Comment: required field "text" is missing'); + } + return Object.freeze({ + kind: 'Comment' as const, + text: input.text, + style: input.style ?? 'line', + }); +} + +/** Visibility modifier for class members / sections. */ +export type Visibility = 'public' | 'protected' | 'private'; diff --git a/packages/abap-ast/src/nodes/class.ts b/packages/abap-ast/src/nodes/class.ts new file mode 100644 index 00000000..79aec809 --- /dev/null +++ b/packages/abap-ast/src/nodes/class.ts @@ -0,0 +1,133 @@ +import type { AbapNode, Identifier, Visibility } from './base'; +import { AbapAstError } from './errors'; +import type { TypeDef } from './types'; +import type { ConstantDecl } from './data'; +import type { AttributeDef, EventDef, MethodDef, MethodImpl } from './members'; + +/** A member that may appear inside a class section. */ +export type SectionMember = + | TypeDef + | AttributeDef + | MethodDef + | ConstantDecl + | EventDef; + +/** A visibility section of a class definition. */ +export interface Section extends AbapNode { + readonly kind: 'Section'; + readonly visibility: Visibility; + readonly members: readonly SectionMember[]; +} + +export function section(input: { + visibility: Visibility; + members?: readonly SectionMember[]; +}): Section { + if (!input.visibility) { + throw new AbapAstError('Section: required field "visibility" is missing'); + } + const members = input.members ?? []; + for (const m of members) { + if ('visibility' in m && m.visibility !== input.visibility) { + throw new AbapAstError( + `Section: member "${m.name}" has visibility "${m.visibility}" but section is "${input.visibility}"`, + ); + } + } + return Object.freeze({ + kind: 'Section' as const, + visibility: input.visibility, + members: Object.freeze([...members]), + }); +} + +/** Top-level global class (`CLASS ... DEFINITION` + `IMPLEMENTATION`). */ +export interface ClassDef extends AbapNode { + readonly kind: 'ClassDef'; + readonly name: Identifier; + readonly superclass?: Identifier; + readonly interfaces: readonly Identifier[]; + readonly isFinal?: boolean; + readonly isAbstract?: boolean; + readonly isForTesting?: boolean; + readonly isCreatePrivate?: boolean; + readonly sections: readonly Section[]; + readonly implementations: readonly MethodImpl[]; +} + +export function classDef(input: { + name: Identifier; + superclass?: Identifier; + interfaces?: readonly Identifier[]; + isFinal?: boolean; + isAbstract?: boolean; + isForTesting?: boolean; + isCreatePrivate?: boolean; + sections?: readonly Section[]; + implementations?: readonly MethodImpl[]; +}): ClassDef { + if (!input.name) { + throw new AbapAstError('ClassDef: required field "name" is missing'); + } + if (input.isFinal && input.isAbstract) { + throw new AbapAstError('ClassDef: class cannot be both FINAL and ABSTRACT'); + } + return Object.freeze({ + kind: 'ClassDef' as const, + name: input.name, + superclass: input.superclass, + interfaces: Object.freeze([...(input.interfaces ?? [])]), + isFinal: input.isFinal, + isAbstract: input.isAbstract, + isForTesting: input.isForTesting, + isCreatePrivate: input.isCreatePrivate, + sections: Object.freeze([...(input.sections ?? [])]), + implementations: Object.freeze([...(input.implementations ?? [])]), + }); +} + +/** A local class inside a CLAS-POOL (same shape as ClassDef, flagged local). */ +export interface LocalClassDef extends AbapNode { + readonly kind: 'LocalClassDef'; + readonly name: Identifier; + readonly superclass?: Identifier; + readonly interfaces: readonly Identifier[]; + readonly isFinal?: boolean; + readonly isAbstract?: boolean; + readonly isForTesting?: boolean; + readonly sections: readonly Section[]; + readonly implementations: readonly MethodImpl[]; + readonly local: true; +} + +export function localClassDef(input: { + name: Identifier; + superclass?: Identifier; + interfaces?: readonly Identifier[]; + isFinal?: boolean; + isAbstract?: boolean; + isForTesting?: boolean; + sections?: readonly Section[]; + implementations?: readonly MethodImpl[]; +}): LocalClassDef { + if (!input.name) { + throw new AbapAstError('LocalClassDef: required field "name" is missing'); + } + if (input.isFinal && input.isAbstract) { + throw new AbapAstError( + 'LocalClassDef: class cannot be both FINAL and ABSTRACT', + ); + } + return Object.freeze({ + kind: 'LocalClassDef' as const, + name: input.name, + superclass: input.superclass, + interfaces: Object.freeze([...(input.interfaces ?? [])]), + isFinal: input.isFinal, + isAbstract: input.isAbstract, + isForTesting: input.isForTesting, + sections: Object.freeze([...(input.sections ?? [])]), + implementations: Object.freeze([...(input.implementations ?? [])]), + local: true as const, + }); +} diff --git a/packages/abap-ast/src/nodes/data.ts b/packages/abap-ast/src/nodes/data.ts new file mode 100644 index 00000000..eda3c8d0 --- /dev/null +++ b/packages/abap-ast/src/nodes/data.ts @@ -0,0 +1,96 @@ +import type { AbapNode, Identifier } from './base'; +import { AbapAstError } from './errors'; +import type { TypeRef } from './types'; +import type { Expression } from './expressions'; + +/** `DATA lv_x TYPE ... [VALUE ...]`. */ +export interface DataDecl extends AbapNode { + readonly kind: 'DataDecl'; + readonly name: Identifier; + readonly type: TypeRef; + readonly initial?: Expression; + readonly classData?: boolean; +} + +export function dataDecl(input: { + name: Identifier; + type: TypeRef; + initial?: Expression; + classData?: boolean; +}): DataDecl { + if (!input.name) { + throw new AbapAstError('DataDecl: required field "name" is missing'); + } + if (!input.type) { + throw new AbapAstError('DataDecl: required field "type" is missing'); + } + return Object.freeze({ + kind: 'DataDecl' as const, + name: input.name, + type: input.type, + initial: input.initial, + classData: input.classData, + }); +} + +/** `CONSTANTS c_x TYPE ... VALUE ...`. */ +export interface ConstantDecl extends AbapNode { + readonly kind: 'ConstantDecl'; + readonly name: Identifier; + readonly type: TypeRef; + readonly value: Expression; + readonly classData?: boolean; +} + +export function constantDecl(input: { + name: Identifier; + type: TypeRef; + value: Expression; + classData?: boolean; +}): ConstantDecl { + if (!input.name) { + throw new AbapAstError('ConstantDecl: required field "name" is missing'); + } + if (!input.type) { + throw new AbapAstError('ConstantDecl: required field "type" is missing'); + } + if (!input.value) { + throw new AbapAstError('ConstantDecl: required field "value" is missing'); + } + return Object.freeze({ + kind: 'ConstantDecl' as const, + name: input.name, + type: input.type, + value: input.value, + classData: input.classData, + }); +} + +/** `FIELD-SYMBOLS TYPE ...`. */ +export interface FieldSymbolDecl extends AbapNode { + readonly kind: 'FieldSymbolDecl'; + readonly name: Identifier; + readonly type: TypeRef; +} + +export function fieldSymbolDecl(input: { + name: Identifier; + type: TypeRef; +}): FieldSymbolDecl { + if (!input.name) { + throw new AbapAstError('FieldSymbolDecl: required field "name" is missing'); + } + if (!input.name.startsWith('<') || !input.name.endsWith('>')) { + throw new AbapAstError( + `FieldSymbolDecl: name "${input.name}" must be wrapped in angle brackets (e.g. )`, + ); + } + if (!input.type) { + throw new AbapAstError('FieldSymbolDecl: required field "type" is missing'); + } + return Object.freeze({ + kind: 'FieldSymbolDecl' as const, + name: input.name, + type: input.type, + }); +} diff --git a/packages/abap-ast/src/nodes/errors.ts b/packages/abap-ast/src/nodes/errors.ts new file mode 100644 index 00000000..8373d84f --- /dev/null +++ b/packages/abap-ast/src/nodes/errors.ts @@ -0,0 +1,17 @@ +export class AbapAstError extends Error { + constructor(message: string) { + super(message); + this.name = 'AbapAstError'; + } +} + +export function requireField( + value: T | undefined | null, + field: string, + nodeKind: string, +): T { + if (value === undefined || value === null || value === '') { + throw new AbapAstError(`${nodeKind}: required field "${field}" is missing`); + } + return value; +} diff --git a/packages/abap-ast/src/nodes/expressions.ts b/packages/abap-ast/src/nodes/expressions.ts new file mode 100644 index 00000000..b320e7c6 --- /dev/null +++ b/packages/abap-ast/src/nodes/expressions.ts @@ -0,0 +1,214 @@ +import type { AbapNode, Identifier } from './base'; +import { AbapAstError } from './errors'; +import type { TypeRef } from './types'; + +/** An expression node (forward-declared union). */ +export type Expression = + | Literal + | IdentifierExpr + | ConstructorExpr + | MethodCallExpr + | BinOp + | StringTemplate + | Cast; + +/** A literal value. */ +export interface Literal extends AbapNode { + readonly kind: 'Literal'; + readonly literalKind: 'string' | 'int' | 'bool' | 'hex'; + readonly value: string | number | boolean; +} + +export function literal(input: { + literalKind: 'string' | 'int' | 'bool' | 'hex'; + value: string | number | boolean; +}): Literal { + if (!input.literalKind) { + throw new AbapAstError('Literal: required field "literalKind" is missing'); + } + if (input.value === undefined || input.value === null) { + throw new AbapAstError('Literal: required field "value" is missing'); + } + if (input.literalKind === 'int' && typeof input.value !== 'number') { + throw new AbapAstError('Literal: int literal requires a number value'); + } + if (input.literalKind === 'bool' && typeof input.value !== 'boolean') { + throw new AbapAstError('Literal: bool literal requires a boolean value'); + } + if ( + (input.literalKind === 'string' || input.literalKind === 'hex') && + typeof input.value !== 'string' + ) { + throw new AbapAstError( + `Literal: ${input.literalKind} literal requires a string value`, + ); + } + return Object.freeze({ + kind: 'Literal' as const, + literalKind: input.literalKind, + value: input.value, + }); +} + +/** A bare identifier used as an expression (variable, constant reference). */ +export interface IdentifierExpr extends AbapNode { + readonly kind: 'IdentifierExpr'; + readonly name: Identifier; +} + +export function identifierExpr(input: { name: Identifier }): IdentifierExpr { + if (!input.name) { + throw new AbapAstError('IdentifierExpr: required field "name" is missing'); + } + return Object.freeze({ + kind: 'IdentifierExpr' as const, + name: input.name, + }); +} + +/** Named argument on a method call / constructor. */ +export interface NamedArg { + readonly name: Identifier; + readonly value: Expression; +} + +/** `NEW ( ... )`. */ +export interface ConstructorExpr extends AbapNode { + readonly kind: 'ConstructorExpr'; + readonly type: TypeRef; + readonly args: readonly NamedArg[]; +} + +export function constructorExpr(input: { + type: TypeRef; + args?: readonly NamedArg[]; +}): ConstructorExpr { + if (!input.type) { + throw new AbapAstError('ConstructorExpr: required field "type" is missing'); + } + return Object.freeze({ + kind: 'ConstructorExpr' as const, + type: input.type, + args: Object.freeze((input.args ?? []).map((a) => Object.freeze({ ...a }))), + }); +} + +/** Chainable method call (static `cl=>m(...)` or instance `ref->m(...)`). */ +export interface MethodCallExpr extends AbapNode { + readonly kind: 'MethodCallExpr'; + readonly receiver: Expression | undefined; + readonly method: Identifier; + readonly callKind: 'static' | 'instance'; + readonly args: readonly NamedArg[]; +} + +export function methodCallExpr(input: { + receiver?: Expression; + method: Identifier; + callKind: 'static' | 'instance'; + args?: readonly NamedArg[]; +}): MethodCallExpr { + if (!input.method) { + throw new AbapAstError( + 'MethodCallExpr: required field "method" is missing', + ); + } + if (input.callKind === 'instance' && !input.receiver) { + throw new AbapAstError('MethodCallExpr: instance calls require a receiver'); + } + return Object.freeze({ + kind: 'MethodCallExpr' as const, + receiver: input.receiver, + method: input.method, + callKind: input.callKind, + args: Object.freeze((input.args ?? []).map((a) => Object.freeze({ ...a }))), + }); +} + +/** Comparison / arithmetic operators. */ +export type BinOperator = + | '=' + | '<>' + | '<' + | '<=' + | '>' + | '>=' + | '+' + | '-' + | '*' + | '/' + | 'AND' + | 'OR'; + +export interface BinOp extends AbapNode { + readonly kind: 'BinOp'; + readonly op: BinOperator; + readonly left: Expression; + readonly right: Expression; +} + +export function binOp(input: { + op: BinOperator; + left: Expression; + right: Expression; +}): BinOp { + if (!input.op) { + throw new AbapAstError('BinOp: required field "op" is missing'); + } + if (!input.left) { + throw new AbapAstError('BinOp: required field "left" is missing'); + } + if (!input.right) { + throw new AbapAstError('BinOp: required field "right" is missing'); + } + return Object.freeze({ + kind: 'BinOp' as const, + op: input.op, + left: input.left, + right: input.right, + }); +} + +/** Parts of a string template — literal text or an interpolated expression. */ +export type StringTemplatePart = + | { readonly partKind: 'text'; readonly text: string } + | { readonly partKind: 'expr'; readonly expr: Expression }; + +/** `|text { expr } more|`. */ +export interface StringTemplate extends AbapNode { + readonly kind: 'StringTemplate'; + readonly parts: readonly StringTemplatePart[]; +} + +export function stringTemplate(input: { + parts: readonly StringTemplatePart[]; +}): StringTemplate { + if (!input.parts) { + throw new AbapAstError('StringTemplate: required field "parts" is missing'); + } + return Object.freeze({ + kind: 'StringTemplate' as const, + parts: Object.freeze(input.parts.map((p) => Object.freeze({ ...p }))), + }); +} + +/** `CAST ( expr )`. */ +export interface Cast extends AbapNode { + readonly kind: 'Cast'; + readonly type: TypeRef; + readonly expr: Expression; +} + +export function cast(input: { type: TypeRef; expr: Expression }): Cast { + if (!input.type) { + throw new AbapAstError('Cast: required field "type" is missing'); + } + if (!input.expr) { + throw new AbapAstError('Cast: required field "expr" is missing'); + } + return Object.freeze({ + kind: 'Cast' as const, + type: input.type, + expr: input.expr, + }); +} diff --git a/packages/abap-ast/src/nodes/index.ts b/packages/abap-ast/src/nodes/index.ts new file mode 100644 index 00000000..b05b7819 --- /dev/null +++ b/packages/abap-ast/src/nodes/index.ts @@ -0,0 +1,9 @@ +export * from './errors'; +export * from './base'; +export * from './types'; +export * from './data'; +export * from './expressions'; +export * from './statements'; +export * from './members'; +export * from './class'; +export * from './interface'; diff --git a/packages/abap-ast/src/nodes/interface.ts b/packages/abap-ast/src/nodes/interface.ts new file mode 100644 index 00000000..c322d404 --- /dev/null +++ b/packages/abap-ast/src/nodes/interface.ts @@ -0,0 +1,36 @@ +import type { AbapNode, Identifier } from './base'; +import { AbapAstError } from './errors'; +import type { TypeDef } from './types'; +import type { MethodDef } from './members'; + +/** A member that may appear inside an interface definition. */ +export type InterfaceMember = TypeDef | MethodDef; + +/** `INTERFACE zif_foo. ... ENDINTERFACE.`. */ +export interface InterfaceDef extends AbapNode { + readonly kind: 'InterfaceDef'; + readonly name: Identifier; + readonly members: readonly InterfaceMember[]; +} + +export function interfaceDef(input: { + name: Identifier; + members?: readonly InterfaceMember[]; +}): InterfaceDef { + if (!input.name) { + throw new AbapAstError('InterfaceDef: required field "name" is missing'); + } + const members = input.members ?? []; + for (const m of members) { + if (m.kind === 'MethodDef' && m.visibility !== 'public') { + throw new AbapAstError( + `InterfaceDef: method "${m.name}" must be public (interfaces expose only public methods)`, + ); + } + } + return Object.freeze({ + kind: 'InterfaceDef' as const, + name: input.name, + members: Object.freeze([...members]), + }); +} diff --git a/packages/abap-ast/src/nodes/members.ts b/packages/abap-ast/src/nodes/members.ts new file mode 100644 index 00000000..92df18c6 --- /dev/null +++ b/packages/abap-ast/src/nodes/members.ts @@ -0,0 +1,209 @@ +import type { AbapNode, Identifier, Visibility } from './base'; +import { AbapAstError } from './errors'; +import type { TypeRef } from './types'; +import type { Expression } from './expressions'; +import type { Statement } from './statements'; + +/** ABAP method parameter kind. */ +export type ParamKind = 'importing' | 'exporting' | 'changing' | 'returning'; + +export interface MethodParam extends AbapNode { + readonly kind: 'MethodParam'; + readonly paramKind: ParamKind; + readonly name: Identifier; + readonly typeRef: TypeRef; + readonly optional?: boolean; + readonly default?: Expression; +} + +export function methodParam(input: { + paramKind: ParamKind; + name: Identifier; + typeRef: TypeRef; + optional?: boolean; + default?: Expression; +}): MethodParam { + if (!input.paramKind) { + throw new AbapAstError( + 'MethodParam: required field "paramKind" is missing', + ); + } + if (!input.name) { + throw new AbapAstError('MethodParam: required field "name" is missing'); + } + if (!input.typeRef) { + throw new AbapAstError('MethodParam: required field "typeRef" is missing'); + } + if (input.paramKind === 'returning' && input.optional) { + throw new AbapAstError( + 'MethodParam: RETURNING parameters cannot be optional', + ); + } + if (input.paramKind === 'returning' && input.default !== undefined) { + throw new AbapAstError( + 'MethodParam: RETURNING parameters cannot have a default', + ); + } + return Object.freeze({ + kind: 'MethodParam' as const, + paramKind: input.paramKind, + name: input.name, + typeRef: input.typeRef, + optional: input.optional, + default: input.default, + }); +} + +export interface MethodDef extends AbapNode { + readonly kind: 'MethodDef'; + readonly name: Identifier; + readonly params: readonly MethodParam[]; + readonly raising: readonly TypeRef[]; + readonly visibility: Visibility; + readonly isClassMethod?: boolean; + readonly isAbstract?: boolean; + readonly isFinal?: boolean; + readonly isRedefinition?: boolean; + readonly isForTesting?: boolean; +} + +export function methodDef(input: { + name: Identifier; + params?: readonly MethodParam[]; + raising?: readonly TypeRef[]; + visibility: Visibility; + isClassMethod?: boolean; + isAbstract?: boolean; + isFinal?: boolean; + isRedefinition?: boolean; + isForTesting?: boolean; +}): MethodDef { + if (!input.name) { + throw new AbapAstError('MethodDef: required field "name" is missing'); + } + if (!input.visibility) { + throw new AbapAstError('MethodDef: required field "visibility" is missing'); + } + const params = input.params ?? []; + // Validate: at most one RETURNING, and if present no EXPORTING/CHANGING. + const returning = params.filter((p) => p.paramKind === 'returning'); + if (returning.length > 1) { + throw new AbapAstError( + 'MethodDef: a method can have at most one RETURNING parameter', + ); + } + if (returning.length === 1) { + const hasExportingOrChanging = params.some( + (p) => p.paramKind === 'exporting' || p.paramKind === 'changing', + ); + if (hasExportingOrChanging) { + throw new AbapAstError( + 'MethodDef: RETURNING parameter cannot be combined with EXPORTING or CHANGING', + ); + } + } + return Object.freeze({ + kind: 'MethodDef' as const, + name: input.name, + params: Object.freeze([...params]), + raising: Object.freeze([...(input.raising ?? [])]), + visibility: input.visibility, + isClassMethod: input.isClassMethod, + isAbstract: input.isAbstract, + isFinal: input.isFinal, + isRedefinition: input.isRedefinition, + isForTesting: input.isForTesting, + }); +} + +/** Body of a method in the IMPLEMENTATION block. */ +export interface MethodImpl extends AbapNode { + readonly kind: 'MethodImpl'; + readonly name: Identifier; + readonly body: readonly Statement[]; +} + +export function methodImpl(input: { + name: Identifier; + body: readonly Statement[]; +}): MethodImpl { + if (!input.name) { + throw new AbapAstError('MethodImpl: required field "name" is missing'); + } + if (!input.body) { + throw new AbapAstError('MethodImpl: required field "body" is missing'); + } + return Object.freeze({ + kind: 'MethodImpl' as const, + name: input.name, + body: Object.freeze([...input.body]), + }); +} + +/** EVENT declaration (stub — body not modelled yet). */ +export interface EventDef extends AbapNode { + readonly kind: 'EventDef'; + readonly name: Identifier; + readonly visibility: Visibility; + readonly isClassEvent?: boolean; +} + +export function eventDef(input: { + name: Identifier; + visibility: Visibility; + isClassEvent?: boolean; +}): EventDef { + if (!input.name) { + throw new AbapAstError('EventDef: required field "name" is missing'); + } + if (!input.visibility) { + throw new AbapAstError('EventDef: required field "visibility" is missing'); + } + return Object.freeze({ + kind: 'EventDef' as const, + name: input.name, + visibility: input.visibility, + isClassEvent: input.isClassEvent, + }); +} + +/** DATA / CLASS-DATA declaration inside a class section. */ +export interface AttributeDef extends AbapNode { + readonly kind: 'AttributeDef'; + readonly name: Identifier; + readonly type: TypeRef; + readonly visibility: Visibility; + readonly classData?: boolean; + readonly readOnly?: boolean; + readonly initial?: Expression; +} + +export function attributeDef(input: { + name: Identifier; + type: TypeRef; + visibility: Visibility; + classData?: boolean; + readOnly?: boolean; + initial?: Expression; +}): AttributeDef { + if (!input.name) { + throw new AbapAstError('AttributeDef: required field "name" is missing'); + } + if (!input.type) { + throw new AbapAstError('AttributeDef: required field "type" is missing'); + } + if (!input.visibility) { + throw new AbapAstError( + 'AttributeDef: required field "visibility" is missing', + ); + } + return Object.freeze({ + kind: 'AttributeDef' as const, + name: input.name, + type: input.type, + visibility: input.visibility, + classData: input.classData, + readOnly: input.readOnly, + initial: input.initial, + }); +} diff --git a/packages/abap-ast/src/nodes/statements.ts b/packages/abap-ast/src/nodes/statements.ts new file mode 100644 index 00000000..c808d0ae --- /dev/null +++ b/packages/abap-ast/src/nodes/statements.ts @@ -0,0 +1,368 @@ +import type { AbapNode, Comment, Identifier } from './base'; +import { AbapAstError } from './errors'; +import type { Expression, NamedArg } from './expressions'; +import type { TypeRef } from './types'; +import type { DataDecl, FieldSymbolDecl } from './data'; + +/** Union of all statement node types. */ +export type Statement = + | Assign + | Call + | Raise + | If + | Loop + | Return + | Try + | Append + | Insert + | Read + | Clear + | Exit + | Continue + | Raw + | Comment + | DataDecl + | FieldSymbolDecl; + +/** `target = value`. */ +export interface Assign extends AbapNode { + readonly kind: 'Assign'; + readonly target: Expression; + readonly value: Expression; +} + +export function assign(input: { + target: Expression; + value: Expression; +}): Assign { + if (!input.target) { + throw new AbapAstError('Assign: required field "target" is missing'); + } + if (!input.value) { + throw new AbapAstError('Assign: required field "value" is missing'); + } + return Object.freeze({ + kind: 'Assign' as const, + target: input.target, + value: input.value, + }); +} + +/** Statement-level method call, e.g. `cl_foo=>bar( iv = 1 ).`. */ +export interface Call extends AbapNode { + readonly kind: 'Call'; + readonly receiver: Expression | undefined; + readonly method: Identifier; + readonly callKind: 'static' | 'instance'; + readonly args: readonly NamedArg[]; +} + +export function call(input: { + receiver?: Expression; + method: Identifier; + callKind: 'static' | 'instance'; + args?: readonly NamedArg[]; +}): Call { + if (!input.method) { + throw new AbapAstError('Call: required field "method" is missing'); + } + if (input.callKind === 'instance' && !input.receiver) { + throw new AbapAstError('Call: instance calls require a receiver'); + } + return Object.freeze({ + kind: 'Call' as const, + receiver: input.receiver, + method: input.method, + callKind: input.callKind, + args: Object.freeze((input.args ?? []).map((a) => Object.freeze({ ...a }))), + }); +} + +/** `RAISE EXCEPTION NEW zcx_xxx( ... ).`. */ +export interface Raise extends AbapNode { + readonly kind: 'Raise'; + readonly exceptionType: TypeRef; + readonly args: readonly NamedArg[]; +} + +export function raise(input: { + exceptionType: TypeRef; + args?: readonly NamedArg[]; +}): Raise { + if (!input.exceptionType) { + throw new AbapAstError('Raise: required field "exceptionType" is missing'); + } + return Object.freeze({ + kind: 'Raise' as const, + exceptionType: input.exceptionType, + args: Object.freeze((input.args ?? []).map((a) => Object.freeze({ ...a }))), + }); +} + +/** A single ELSEIF branch. */ +export interface ElseIfBranch { + readonly condition: Expression; + readonly body: readonly Statement[]; +} + +/** IF / ELSEIF / ELSE. */ +export interface If extends AbapNode { + readonly kind: 'If'; + readonly condition: Expression; + readonly then: readonly Statement[]; + readonly elseIfs: readonly ElseIfBranch[]; + readonly else?: readonly Statement[]; +} + +export function ifStmt(input: { + condition: Expression; + then: readonly Statement[]; + elseIfs?: readonly ElseIfBranch[]; + else?: readonly Statement[]; +}): If { + if (!input.condition) { + throw new AbapAstError('If: required field "condition" is missing'); + } + if (!input.then) { + throw new AbapAstError('If: required field "then" is missing'); + } + return Object.freeze({ + kind: 'If' as const, + condition: input.condition, + then: Object.freeze([...input.then]), + elseIfs: Object.freeze( + (input.elseIfs ?? []).map((b) => + Object.freeze({ ...b, body: Object.freeze([...b.body]) }), + ), + ), + else: input.else ? Object.freeze([...input.else]) : undefined, + }); +} + +/** LOOP AT ... INTO wa / ASSIGNING . */ +export interface Loop extends AbapNode { + readonly kind: 'Loop'; + readonly table: Expression; + readonly binding: + | { readonly bindKind: 'into'; readonly target: Identifier } + | { readonly bindKind: 'assigning'; readonly fieldSymbol: Identifier }; + readonly body: readonly Statement[]; +} + +export function loop(input: { + table: Expression; + binding: Loop['binding']; + body: readonly Statement[]; +}): Loop { + if (!input.table) { + throw new AbapAstError('Loop: required field "table" is missing'); + } + if (!input.binding) { + throw new AbapAstError('Loop: required field "binding" is missing'); + } + if (input.binding.bindKind === 'assigning') { + const fs = input.binding.fieldSymbol; + if (!fs.startsWith('<') || !fs.endsWith('>')) { + throw new AbapAstError( + `Loop: assigning target "${fs}" must be a field symbol wrapped in angle brackets`, + ); + } + } + if (!input.body) { + throw new AbapAstError('Loop: required field "body" is missing'); + } + return Object.freeze({ + kind: 'Loop' as const, + table: input.table, + binding: Object.freeze({ ...input.binding }), + body: Object.freeze([...input.body]), + }); +} + +/** `RETURN.` or `rv = ...` followed by `RETURN.`. */ +export interface Return extends AbapNode { + readonly kind: 'Return'; + readonly value?: Expression; +} + +export function returnStmt(input?: { value?: Expression }): Return { + return Object.freeze({ + kind: 'Return' as const, + value: input?.value, + }); +} + +/** CATCH clause inside a TRY. */ +export interface CatchClause { + readonly exceptionTypes: readonly TypeRef[]; + readonly into?: Identifier; + readonly body: readonly Statement[]; +} + +/** TRY / CATCH / CLEANUP. */ +export interface Try extends AbapNode { + readonly kind: 'Try'; + readonly body: readonly Statement[]; + readonly catches: readonly CatchClause[]; + readonly cleanup?: readonly Statement[]; +} + +export function tryStmt(input: { + body: readonly Statement[]; + catches: readonly CatchClause[]; + cleanup?: readonly Statement[]; +}): Try { + if (!input.body) { + throw new AbapAstError('Try: required field "body" is missing'); + } + if (!input.catches || input.catches.length === 0) { + throw new AbapAstError('Try: must declare at least one CATCH clause'); + } + for (const c of input.catches) { + if (!c.exceptionTypes || c.exceptionTypes.length === 0) { + throw new AbapAstError( + 'Try: CATCH clause must declare at least one exception type', + ); + } + } + return Object.freeze({ + kind: 'Try' as const, + body: Object.freeze([...input.body]), + catches: Object.freeze( + input.catches.map((c) => + Object.freeze({ + ...c, + exceptionTypes: Object.freeze([...c.exceptionTypes]), + body: Object.freeze([...c.body]), + }), + ), + ), + cleanup: input.cleanup ? Object.freeze([...input.cleanup]) : undefined, + }); +} + +/** APPEND value TO table. */ +export interface Append extends AbapNode { + readonly kind: 'Append'; + readonly value: Expression; + readonly table: Expression; +} + +export function append(input: { + value: Expression; + table: Expression; +}): Append { + if (!input.value) { + throw new AbapAstError('Append: required field "value" is missing'); + } + if (!input.table) { + throw new AbapAstError('Append: required field "table" is missing'); + } + return Object.freeze({ + kind: 'Append' as const, + value: input.value, + table: input.table, + }); +} + +/** INSERT value INTO TABLE table. */ +export interface Insert extends AbapNode { + readonly kind: 'Insert'; + readonly value: Expression; + readonly table: Expression; +} + +export function insert(input: { + value: Expression; + table: Expression; +}): Insert { + if (!input.value) { + throw new AbapAstError('Insert: required field "value" is missing'); + } + if (!input.table) { + throw new AbapAstError('Insert: required field "table" is missing'); + } + return Object.freeze({ + kind: 'Insert' as const, + value: input.value, + table: input.table, + }); +} + +/** READ TABLE table INTO wa / ASSIGNING [WITH KEY ...]. */ +export interface Read extends AbapNode { + readonly kind: 'Read'; + readonly table: Expression; + readonly binding: + | { readonly bindKind: 'into'; readonly target: Identifier } + | { readonly bindKind: 'assigning'; readonly fieldSymbol: Identifier }; + readonly withKey?: readonly NamedArg[]; + readonly index?: Expression; +} + +export function read(input: { + table: Expression; + binding: Read['binding']; + withKey?: readonly NamedArg[]; + index?: Expression; +}): Read { + if (!input.table) { + throw new AbapAstError('Read: required field "table" is missing'); + } + if (!input.binding) { + throw new AbapAstError('Read: required field "binding" is missing'); + } + return Object.freeze({ + kind: 'Read' as const, + table: input.table, + binding: Object.freeze({ ...input.binding }), + withKey: input.withKey + ? Object.freeze(input.withKey.map((a) => Object.freeze({ ...a }))) + : undefined, + index: input.index, + }); +} + +/** CLEAR target. */ +export interface Clear extends AbapNode { + readonly kind: 'Clear'; + readonly target: Expression; +} + +export function clear(input: { target: Expression }): Clear { + if (!input.target) { + throw new AbapAstError('Clear: required field "target" is missing'); + } + return Object.freeze({ kind: 'Clear' as const, target: input.target }); +} + +/** EXIT. */ +export interface Exit extends AbapNode { + readonly kind: 'Exit'; +} + +export function exit(): Exit { + return Object.freeze({ kind: 'Exit' as const }); +} + +/** CONTINUE. */ +export interface Continue extends AbapNode { + readonly kind: 'Continue'; +} + +export function continueStmt(): Continue { + return Object.freeze({ kind: 'Continue' as const }); +} + +/** Verbatim ABAP source — escape hatch when the AST can't express something. */ +export interface Raw extends AbapNode { + readonly kind: 'Raw'; + readonly source: string; +} + +export function raw(input: { source: string }): Raw { + if (typeof input.source !== 'string' || input.source.length === 0) { + throw new AbapAstError('Raw: required field "source" is missing'); + } + return Object.freeze({ kind: 'Raw' as const, source: input.source }); +} diff --git a/packages/abap-ast/src/nodes/types.ts b/packages/abap-ast/src/nodes/types.ts new file mode 100644 index 00000000..7ec9dd5a --- /dev/null +++ b/packages/abap-ast/src/nodes/types.ts @@ -0,0 +1,196 @@ +import type { AbapNode, Identifier } from './base'; +import { AbapAstError } from './errors'; + +/** ABAP builtin type names allowed in the AST. */ +export type BuiltinTypeName = + | 'i' + | 'int8' + | 'f' + | 'decfloat34' + | 'd' + | 't' + | 'timestampl' + | 'string' + | 'xstring' + | 'abap_bool' + | 'sysuuid_x16'; + +const BUILTIN_TYPE_NAMES: ReadonlySet = + new Set([ + 'i', + 'int8', + 'f', + 'decfloat34', + 'd', + 't', + 'timestampl', + 'string', + 'xstring', + 'abap_bool', + 'sysuuid_x16', + ]); + +export interface BuiltinType extends AbapNode { + readonly kind: 'BuiltinType'; + readonly name: BuiltinTypeName; + readonly length?: number; + readonly decimals?: number; +} + +export function builtinType(input: { + name: BuiltinTypeName; + length?: number; + decimals?: number; +}): BuiltinType { + if (!input.name) { + throw new AbapAstError('BuiltinType: required field "name" is missing'); + } + if (!BUILTIN_TYPE_NAMES.has(input.name)) { + throw new AbapAstError( + `BuiltinType: unknown builtin type "${String(input.name)}"`, + ); + } + return Object.freeze({ + kind: 'BuiltinType' as const, + name: input.name, + length: input.length, + decimals: input.decimals, + }); +} + +/** Reference to a named type (e.g. `zif_foo=>ty_bar`). */ +export interface NamedTypeRef extends AbapNode { + readonly kind: 'NamedTypeRef'; + readonly name: Identifier; +} + +export function namedTypeRef(input: { name: Identifier }): NamedTypeRef { + if (!input.name) { + throw new AbapAstError('NamedTypeRef: required field "name" is missing'); + } + return Object.freeze({ kind: 'NamedTypeRef' as const, name: input.name }); +} + +/** Type reference used in declarations. */ +export type TypeRef = BuiltinType | NamedTypeRef | TableType | StructureType; + +/** ABAP internal table type. */ +export interface TableType extends AbapNode { + readonly kind: 'TableType'; + readonly rowType: TypeRef; + readonly tableKind: 'standard' | 'sorted' | 'hashed'; + readonly uniqueness?: 'unique' | 'non-unique'; + readonly keyFields?: readonly Identifier[]; +} + +export function tableType(input: { + rowType: TypeRef; + tableKind?: 'standard' | 'sorted' | 'hashed'; + uniqueness?: 'unique' | 'non-unique'; + keyFields?: readonly Identifier[]; +}): TableType { + if (!input.rowType) { + throw new AbapAstError('TableType: required field "rowType" is missing'); + } + return Object.freeze({ + kind: 'TableType' as const, + rowType: input.rowType, + tableKind: input.tableKind ?? 'standard', + uniqueness: input.uniqueness, + keyFields: input.keyFields + ? Object.freeze([...input.keyFields]) + : undefined, + }); +} + +/** Field of a structure type. */ +export interface StructureField { + readonly name: Identifier; + readonly type: TypeRef; +} + +/** ABAP `BEGIN OF ... END OF` structure type. */ +export interface StructureType extends AbapNode { + readonly kind: 'StructureType'; + readonly fields: readonly StructureField[]; +} + +export function structureType(input: { + fields: readonly StructureField[]; +}): StructureType { + if (!input.fields || input.fields.length === 0) { + throw new AbapAstError('StructureType: must declare at least one field'); + } + for (const f of input.fields) { + if (!f.name) { + throw new AbapAstError('StructureType: field is missing "name"'); + } + if (!f.type) { + throw new AbapAstError( + `StructureType: field "${f.name}" is missing "type"`, + ); + } + } + return Object.freeze({ + kind: 'StructureType' as const, + fields: Object.freeze(input.fields.map((f) => Object.freeze({ ...f }))), + }); +} + +/** Enum-like type: a fixed set of name/value pairs with a base type. */ +export interface EnumMember { + readonly name: Identifier; + readonly value: string | number; +} + +export interface EnumType extends AbapNode { + readonly kind: 'EnumType'; + readonly baseType: TypeRef; + readonly members: readonly EnumMember[]; +} + +export function enumType(input: { + baseType: TypeRef; + members: readonly EnumMember[]; +}): EnumType { + if (!input.baseType) { + throw new AbapAstError('EnumType: required field "baseType" is missing'); + } + if (!input.members || input.members.length === 0) { + throw new AbapAstError('EnumType: must declare at least one member'); + } + for (const m of input.members) { + if (!m.name) { + throw new AbapAstError('EnumType: member is missing "name"'); + } + } + return Object.freeze({ + kind: 'EnumType' as const, + baseType: input.baseType, + members: Object.freeze(input.members.map((m) => Object.freeze({ ...m }))), + }); +} + +/** Top-level `TYPES: TYPE ...` declaration. */ +export interface TypeDef extends AbapNode { + readonly kind: 'TypeDef'; + readonly name: Identifier; + readonly type: TypeRef | EnumType; +} + +export function typeDef(input: { + name: Identifier; + type: TypeRef | EnumType; +}): TypeDef { + if (!input.name) { + throw new AbapAstError('TypeDef: required field "name" is missing'); + } + if (!input.type) { + throw new AbapAstError('TypeDef: required field "type" is missing'); + } + return Object.freeze({ + kind: 'TypeDef' as const, + name: input.name, + type: input.type, + }); +} diff --git a/packages/abap-ast/src/printer/index.ts b/packages/abap-ast/src/printer/index.ts new file mode 100644 index 00000000..0b9f221c --- /dev/null +++ b/packages/abap-ast/src/printer/index.ts @@ -0,0 +1,138 @@ +import type { AbapNode } from '../nodes/base'; +import type { PrintOptions } from './options'; +import { resolveOptions } from './options'; +import { Writer } from './writer'; +import { printTypeDef } from './print-types'; +import { printExpression } from './print-expressions'; +import { + printStatement, + printDataDecl, + printFieldSymbolDecl, + printComment, +} from './print-statements'; +import { + printAttributeDef, + printConstantDecl, + printEventDef, + printMethodDef, + printMethodImpl, +} from './print-members'; +import { printClassDef, printLocalClassDef } from './print-class'; +import { printInterfaceDef } from './print-interface'; + +export type { PrintOptions, ResolvedPrintOptions } from './options'; +export { Writer } from './writer'; +export { printInlineType } from './print-types'; +export { printExpression } from './print-expressions'; + +/** Pretty-print an ABAP AST node to source code. */ +export function print(node: AbapNode, options?: PrintOptions): string { + const writer = new Writer(resolveOptions(options)); + printNode(node, writer); + return writer.toString(); +} + +function printNode(node: AbapNode, writer: Writer): void { + switch (node.kind) { + // Top-level / structural + case 'ClassDef': + printClassDef(node as Parameters[0], writer); + return; + case 'LocalClassDef': + printLocalClassDef( + node as Parameters[0], + writer, + ); + return; + case 'InterfaceDef': + printInterfaceDef( + node as Parameters[0], + writer, + ); + return; + // Type-level + case 'TypeDef': + printTypeDef(node as Parameters[0], writer); + return; + case 'BuiltinType': + case 'NamedTypeRef': + case 'TableType': + case 'StructureType': + case 'EnumType': + throw new Error( + `print: ${node.kind} is not a top-level node — wrap it in a TypeDef`, + ); + // Members + case 'AttributeDef': + printAttributeDef( + node as Parameters[0], + writer, + ); + return; + case 'MethodDef': + printMethodDef(node as Parameters[0], writer); + return; + case 'MethodImpl': + printMethodImpl(node as Parameters[0], writer); + return; + case 'EventDef': + printEventDef(node as Parameters[0], writer); + return; + case 'ConstantDecl': + printConstantDecl( + node as Parameters[0], + writer, + ); + return; + case 'Section': + throw new Error( + 'print: Section is an internal node — print its enclosing ClassDef/LocalClassDef instead', + ); + case 'MethodParam': + throw new Error( + 'print: MethodParam is an internal node — print its enclosing MethodDef', + ); + // Data (as statement) + case 'DataDecl': + printDataDecl(node as Parameters[0], writer); + return; + case 'FieldSymbolDecl': + printFieldSymbolDecl( + node as Parameters[0], + writer, + ); + return; + // Statements + case 'Assign': + case 'Call': + case 'Raise': + case 'If': + case 'Loop': + case 'Return': + case 'Try': + case 'Append': + case 'Insert': + case 'Read': + case 'Clear': + case 'Exit': + case 'Continue': + case 'Raw': + printStatement(node as Parameters[0], writer); + return; + case 'Comment': + printComment(node as Parameters[0], writer); + return; + // Expressions (printed as a single-line expression — no trailing period) + case 'Literal': + case 'IdentifierExpr': + case 'ConstructorExpr': + case 'MethodCallExpr': + case 'BinOp': + case 'StringTemplate': + case 'Cast': + writer.writeLine( + printExpression(node as Parameters[0], writer), + ); + return; + } +} diff --git a/packages/abap-ast/src/printer/options.ts b/packages/abap-ast/src/printer/options.ts new file mode 100644 index 00000000..278bd460 --- /dev/null +++ b/packages/abap-ast/src/printer/options.ts @@ -0,0 +1,18 @@ +export interface PrintOptions { + /** Spaces per indentation level. Default 2. */ + indent?: number; + /** 'upper' (default) or 'lower' for ABAP keywords. */ + keywordCase?: 'upper' | 'lower'; + /** Line ending. Default '\n'. */ + eol?: string; +} + +export type ResolvedPrintOptions = Required; + +export function resolveOptions(o?: PrintOptions): ResolvedPrintOptions { + return { + indent: o?.indent ?? 2, + keywordCase: o?.keywordCase ?? 'upper', + eol: o?.eol ?? '\n', + }; +} diff --git a/packages/abap-ast/src/printer/print-class.ts b/packages/abap-ast/src/printer/print-class.ts new file mode 100644 index 00000000..4e477385 --- /dev/null +++ b/packages/abap-ast/src/printer/print-class.ts @@ -0,0 +1,148 @@ +import type { Writer } from './writer'; +import type { ClassDef, LocalClassDef, Section } from '../nodes/class'; +import type { Visibility } from '../nodes/base'; +import { printTypeDef } from './print-types'; +import { + printAttributeDef, + printConstantDecl, + printEventDef, + printMethodDef, + printMethodImpl, +} from './print-members'; + +const VIS_KW: Record = { + public: 'PUBLIC SECTION', + protected: 'PROTECTED SECTION', + private: 'PRIVATE SECTION', +}; + +function printSection(sec: Section, writer: Writer): void { + writer.writeLine(`${writer.kw(VIS_KW[sec.visibility])}.`); + writer.indent(); + for (const m of sec.members) { + switch (m.kind) { + case 'TypeDef': + printTypeDef(m, writer); + break; + case 'AttributeDef': + printAttributeDef(m, writer); + break; + case 'MethodDef': + printMethodDef(m, writer); + break; + case 'ConstantDecl': + printConstantDecl(m, writer); + break; + case 'EventDef': + printEventDef(m, writer); + break; + } + } + writer.dedent(); +} + +function printDefinitionHeader( + name: string, + opts: { + readonly local: boolean; + readonly superclass?: string; + readonly interfaces: readonly string[]; + readonly isFinal?: boolean; + readonly isAbstract?: boolean; + readonly isForTesting?: boolean; + readonly isCreatePrivate?: boolean; + }, + writer: Writer, +): void { + const K = (s: string): string => writer.kw(s); + const parts: string[] = [`${K('CLASS')} ${name} ${K('DEFINITION')}`]; + if (!opts.local) { + parts.push(K('PUBLIC')); + } + if (opts.isFinal) parts.push(K('FINAL')); + if (opts.isAbstract) parts.push(K('ABSTRACT')); + if (opts.isForTesting) parts.push(K('FOR TESTING')); + if (opts.superclass) { + parts.push(`${K('INHERITING FROM')} ${opts.superclass}`); + } + if (!opts.local) { + parts.push(opts.isCreatePrivate ? K('CREATE PRIVATE') : K('CREATE PUBLIC')); + } + writer.writeLine(parts.join(' ') + '.'); + if (opts.interfaces.length > 0) { + writer.indent(); + for (const i of opts.interfaces) { + writer.writeLine(`${K('INTERFACES')} ${i}.`); + } + writer.dedent(); + } +} + +function printSections(sections: readonly Section[], writer: Writer): void { + for (const s of sections) { + printSection(s, writer); + } +} + +function printImplementations( + name: string, + impls: ReadonlyArray, + writer: Writer, +): void { + const K = (s: string): string => writer.kw(s); + writer.writeLine(`${K('CLASS')} ${name} ${K('IMPLEMENTATION')}.`); + writer.indent(); + impls.forEach((impl, idx) => { + if (idx > 0) writer.blank(); + printMethodImpl(impl, writer); + }); + writer.dedent(); + writer.writeLine(`${K('ENDCLASS')}.`); +} + +export function printClassDef(node: ClassDef, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + printDefinitionHeader( + node.name, + { + local: false, + superclass: node.superclass, + interfaces: node.interfaces, + isFinal: node.isFinal, + isAbstract: node.isAbstract, + isForTesting: node.isForTesting, + isCreatePrivate: node.isCreatePrivate, + }, + writer, + ); + writer.indent(); + printSections(node.sections, writer); + writer.dedent(); + writer.writeLine(`${K('ENDCLASS')}.`); + writer.blank(); + printImplementations(node.name, node.implementations, writer); +} + +export function printLocalClassDef(node: LocalClassDef, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + printDefinitionHeader( + node.name, + { + local: true, + superclass: node.superclass, + interfaces: node.interfaces, + isFinal: node.isFinal, + isAbstract: node.isAbstract, + isForTesting: node.isForTesting, + }, + writer, + ); + writer.indent(); + printSections(node.sections, writer); + writer.dedent(); + writer.writeLine(`${K('ENDCLASS')}.`); + if (node.implementations.length > 0) { + writer.blank(); + printImplementations(node.name, node.implementations, writer); + } +} diff --git a/packages/abap-ast/src/printer/print-expressions.ts b/packages/abap-ast/src/printer/print-expressions.ts new file mode 100644 index 00000000..d381a99d --- /dev/null +++ b/packages/abap-ast/src/printer/print-expressions.ts @@ -0,0 +1,106 @@ +import type { Writer } from './writer'; +import type { + Expression, + NamedArg, + StringTemplate, +} from '../nodes/expressions'; +import { printInlineType } from './print-types'; + +/** Print an expression to a single string (no newlines unless forced by wrapping). */ +export function printExpression(expr: Expression, writer: Writer): string { + switch (expr.kind) { + case 'Literal': + return printLiteral(expr.literalKind, expr.value); + case 'IdentifierExpr': + return expr.name; + case 'ConstructorExpr': { + const type = printInlineType(expr.type, writer); + const args = printArgs(expr.args, writer, 0); + return `${writer.kw('NEW')} ${type}(${args})`; + } + case 'MethodCallExpr': { + const args = printArgs(expr.args, writer, 0); + if (expr.callKind === 'static') { + const recv = + expr.receiver !== undefined + ? printExpression(expr.receiver, writer) + : ''; + const head = recv.length > 0 ? `${recv}=>${expr.method}` : expr.method; + return `${head}(${args})`; + } + if (expr.receiver === undefined) { + throw new Error('MethodCallExpr: instance call has no receiver'); + } + const recv = printExpression(expr.receiver, writer); + return `${recv}->${expr.method}(${args})`; + } + case 'BinOp': { + const left = printExpression(expr.left, writer); + const right = printExpression(expr.right, writer); + const op = + expr.op === 'AND' || expr.op === 'OR' ? writer.kw(expr.op) : expr.op; + return `${left} ${op} ${right}`; + } + case 'StringTemplate': + return printStringTemplate(expr, writer); + case 'Cast': { + const type = printInlineType(expr.type, writer); + const inner = printExpression(expr.expr, writer); + return `${writer.kw('CAST')} ${type}( ${inner} )`; + } + } +} + +function printLiteral( + kind: 'string' | 'int' | 'bool' | 'hex', + value: string | number | boolean, +): string { + switch (kind) { + case 'string': + return `'${String(value).replace(/'/g, `''`)}'`; + case 'int': + return String(value); + case 'bool': + return (value as boolean) ? 'abap_true' : 'abap_false'; + case 'hex': + return `'${String(value)}'`; + } +} + +/** Print the inside of `( ... )` for a named-arg list. Empty → ' ' (ABAP convention). */ +export function printArgs( + args: readonly NamedArg[], + writer: Writer, + currentColumn: number, +): string { + if (args.length === 0) { + return ' '; + } + const single = args + .map((a) => `${a.name} = ${printExpression(a.value, writer)}`) + .join(' '); + const singleLine = ` ${single} `; + // Heuristic: wrap when total column would exceed 80. + if (currentColumn + singleLine.length + 2 <= 80) { + return singleLine; + } + const eol = writer.options.eol; + const pad = ' '.repeat(currentColumn + writer.options.indent); + const parts = args.map( + (a) => `${pad}${a.name} = ${printExpression(a.value, writer)}`, + ); + return eol + parts.join(eol) + eol + ' '.repeat(currentColumn); +} + +function printStringTemplate(node: StringTemplate, writer: Writer): string { + let out = '|'; + for (const part of node.parts) { + if (part.partKind === 'text') { + out += part.text; + } else { + out += `{ ${printExpression(part.expr, writer)} }`; + } + } + out += '|'; + return out; +} diff --git a/packages/abap-ast/src/printer/print-interface.ts b/packages/abap-ast/src/printer/print-interface.ts new file mode 100644 index 00000000..cdb62d29 --- /dev/null +++ b/packages/abap-ast/src/printer/print-interface.ts @@ -0,0 +1,22 @@ +import type { Writer } from './writer'; +import type { InterfaceDef } from '../nodes/interface'; +import { printTypeDef } from './print-types'; +import { printMethodDef } from './print-members'; + +export function printInterfaceDef(node: InterfaceDef, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + writer.writeLine(`${K('INTERFACE')} ${node.name} ${K('PUBLIC')}.`); + writer.indent(); + for (const m of node.members) { + switch (m.kind) { + case 'TypeDef': + printTypeDef(m, writer); + break; + case 'MethodDef': + printMethodDef(m, writer); + break; + } + } + writer.dedent(); + writer.writeLine(`${K('ENDINTERFACE')}.`); +} diff --git a/packages/abap-ast/src/printer/print-members.ts b/packages/abap-ast/src/printer/print-members.ts new file mode 100644 index 00000000..25296efd --- /dev/null +++ b/packages/abap-ast/src/printer/print-members.ts @@ -0,0 +1,148 @@ +import type { Writer } from './writer'; +import type { + AttributeDef, + EventDef, + MethodDef, + MethodImpl, + MethodParam, +} from '../nodes/members'; +import type { ConstantDecl } from '../nodes/data'; +import { printInlineType } from './print-types'; +import { printExpression } from './print-expressions'; +import { printStatements } from './print-statements'; + +export function printAttributeDef(node: AttributeDef, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + const kw = node.classData ? K('CLASS-DATA') : K('DATA'); + const type = printInlineType(node.type, writer); + let line = `${kw} ${node.name} ${K('TYPE')} ${type}`; + if (node.readOnly) { + line += ` ${K('READ-ONLY')}`; + } + if (node.initial !== undefined) { + line += ` ${K('VALUE')} ${printExpression(node.initial, writer)}`; + } + writer.writeLine(`${line}.`); +} + +export function printConstantDecl(node: ConstantDecl, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + const kw = node.classData ? K('CLASS-CONSTANTS') : K('CONSTANTS'); + const type = printInlineType(node.type, writer); + writer.writeLine( + `${kw} ${node.name} ${K('TYPE')} ${type} ${K('VALUE')} ${printExpression(node.value, writer)}.`, + ); +} + +export function printEventDef(node: EventDef, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + const kw = node.isClassEvent ? K('CLASS-EVENTS') : K('EVENTS'); + writer.writeLine(`${kw} ${node.name}.`); +} + +const PARAM_KW: Record = { + importing: 'IMPORTING', + exporting: 'EXPORTING', + changing: 'CHANGING', + returning: 'RETURNING', +}; + +/** Group params by kind (preserving AST order within each group). */ +function groupParams( + params: readonly MethodParam[], +): ReadonlyArray<{ kind: MethodParam['paramKind']; items: MethodParam[] }> { + const order: MethodParam['paramKind'][] = [ + 'importing', + 'exporting', + 'changing', + 'returning', + ]; + const result: { kind: MethodParam['paramKind']; items: MethodParam[] }[] = []; + for (const k of order) { + const items = params.filter((p) => p.paramKind === k); + if (items.length > 0) { + result.push({ kind: k, items }); + } + } + return result; +} + +function printParamBody(p: MethodParam, writer: Writer): string { + const K = (s: string): string => writer.kw(s); + const type = printInlineType(p.typeRef, writer); + let body: string; + if (p.paramKind === 'returning') { + body = `${K('VALUE')}(${p.name}) ${K('TYPE')} ${type}`; + } else { + body = `${p.name} ${K('TYPE')} ${type}`; + } + if (p.optional) { + body += ` ${K('OPTIONAL')}`; + } + if (p.default !== undefined) { + body += ` ${K('DEFAULT')} ${printExpression(p.default, writer)}`; + } + return body; +} + +export function printMethodDef(node: MethodDef, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + const flagsPrefix = node.isClassMethod ? K('CLASS-METHODS') : K('METHODS'); + const suffixFlags: string[] = []; + if (node.isAbstract) suffixFlags.push(K('ABSTRACT')); + if (node.isFinal) suffixFlags.push(K('FINAL')); + if (node.isRedefinition) suffixFlags.push(K('REDEFINITION')); + if (node.isForTesting) suffixFlags.push(K('FOR TESTING')); + + const groups = groupParams(node.params); + const hasAny = groups.length > 0 || node.raising.length > 0; + + if (!hasAny && suffixFlags.length === 0) { + writer.writeLine(`${flagsPrefix} ${node.name}.`); + return; + } + + if (!hasAny) { + writer.writeLine(`${flagsPrefix} ${node.name} ${suffixFlags.join(' ')}.`); + return; + } + + // Header line + writer.writeLine(`${flagsPrefix} ${node.name}`); + writer.indent(); + // Parameter groups + for (const g of groups) { + const kw = K(PARAM_KW[g.kind]); + if (g.items.length === 1) { + writer.writeLine(`${kw} ${printParamBody(g.items[0], writer)}`); + } else { + writer.writeLine(kw); + writer.indent(); + g.items.forEach((p) => { + writer.writeLine(printParamBody(p, writer)); + }); + writer.dedent(); + } + } + if (node.raising.length > 0) { + const types = node.raising.map((t) => printInlineType(t, writer)).join(' '); + writer.writeLine(`${K('RAISING')} ${types}`); + } + if (suffixFlags.length > 0) { + writer.writeLine(suffixFlags.join(' ')); + } + writer.dedent(); + // Terminate last emitted line with a period. + // Find the last non-empty line and add '.'. + // The writer's toString uses internal lines[]; append '.' via write(). + writer.write('.'); +} + +export function printMethodImpl(node: MethodImpl, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + writer.writeLine(`${K('METHOD')} ${node.name}.`); + writer.indent(); + printStatements(node.body, writer); + writer.dedent(); + writer.writeLine(`${K('ENDMETHOD')}.`); +} diff --git a/packages/abap-ast/src/printer/print-statements.ts b/packages/abap-ast/src/printer/print-statements.ts new file mode 100644 index 00000000..f6377551 --- /dev/null +++ b/packages/abap-ast/src/printer/print-statements.ts @@ -0,0 +1,220 @@ +import type { Writer } from './writer'; +import type { Statement } from '../nodes/statements'; +import type { DataDecl, FieldSymbolDecl } from '../nodes/data'; +import type { Comment } from '../nodes/base'; +import { printExpression, printArgs } from './print-expressions'; +import { printInlineType } from './print-types'; + +export function printStatements( + stmts: readonly Statement[], + writer: Writer, +): void { + for (const s of stmts) { + printStatement(s, writer); + } +} + +export function printStatement(stmt: Statement, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + switch (stmt.kind) { + case 'Comment': + printComment(stmt, writer); + return; + case 'DataDecl': + printDataDecl(stmt, writer); + return; + case 'FieldSymbolDecl': + printFieldSymbolDecl(stmt, writer); + return; + case 'Assign': { + const target = printExpression(stmt.target, writer); + const value = printExpression(stmt.value, writer); + writer.writeLine(`${target} = ${value}.`); + return; + } + case 'Call': { + const col = writer.prefix().length; + if (stmt.callKind === 'static') { + const recv = + stmt.receiver !== undefined + ? printExpression(stmt.receiver, writer) + : ''; + const head = recv.length > 0 ? `${recv}=>${stmt.method}` : stmt.method; + const args = printArgs(stmt.args, writer, col + head.length + 1); + writer.writeLine(`${head}(${args}).`); + } else { + if (stmt.receiver === undefined) { + throw new Error('Call: instance call has no receiver'); + } + const recv = printExpression(stmt.receiver, writer); + const head = `${recv}->${stmt.method}`; + const args = printArgs(stmt.args, writer, col + head.length + 1); + writer.writeLine(`${head}(${args}).`); + } + return; + } + case 'Raise': { + const col = writer.prefix().length; + const type = printInlineType(stmt.exceptionType, writer); + const head = `${K('RAISE EXCEPTION')} ${K('NEW')} ${type}`; + const args = printArgs(stmt.args, writer, col + head.length + 1); + writer.writeLine(`${head}(${args}).`); + return; + } + case 'If': { + writer.writeLine( + `${K('IF')} ${printExpression(stmt.condition, writer)}.`, + ); + writer.indent(); + printStatements(stmt.then, writer); + writer.dedent(); + for (const b of stmt.elseIfs) { + writer.writeLine( + `${K('ELSEIF')} ${printExpression(b.condition, writer)}.`, + ); + writer.indent(); + printStatements(b.body, writer); + writer.dedent(); + } + if (stmt.else) { + writer.writeLine(`${K('ELSE')}.`); + writer.indent(); + printStatements(stmt.else, writer); + writer.dedent(); + } + writer.writeLine(`${K('ENDIF')}.`); + return; + } + case 'Loop': { + const table = printExpression(stmt.table, writer); + const bind = + stmt.binding.bindKind === 'into' + ? `${K('INTO')} ${stmt.binding.target}` + : `${K('ASSIGNING')} ${stmt.binding.fieldSymbol}`; + writer.writeLine(`${K('LOOP AT')} ${table} ${bind}.`); + writer.indent(); + printStatements(stmt.body, writer); + writer.dedent(); + writer.writeLine(`${K('ENDLOOP')}.`); + return; + } + case 'Return': { + if (stmt.value !== undefined) { + writer.writeLine(`rv = ${printExpression(stmt.value, writer)}.`); + } + writer.writeLine(`${K('RETURN')}.`); + return; + } + case 'Try': { + writer.writeLine(`${K('TRY')}.`); + writer.indent(); + printStatements(stmt.body, writer); + writer.dedent(); + for (const c of stmt.catches) { + const types = c.exceptionTypes + .map((t) => printInlineType(t, writer)) + .join(' '); + const into = c.into ? ` ${K('INTO')} ${c.into}` : ''; + writer.writeLine(`${K('CATCH')} ${types}${into}.`); + writer.indent(); + printStatements(c.body, writer); + writer.dedent(); + } + if (stmt.cleanup) { + writer.writeLine(`${K('CLEANUP')}.`); + writer.indent(); + printStatements(stmt.cleanup, writer); + writer.dedent(); + } + writer.writeLine(`${K('ENDTRY')}.`); + return; + } + case 'Append': { + const v = printExpression(stmt.value, writer); + const t = printExpression(stmt.table, writer); + writer.writeLine(`${K('APPEND')} ${v} ${K('TO')} ${t}.`); + return; + } + case 'Insert': { + const v = printExpression(stmt.value, writer); + const t = printExpression(stmt.table, writer); + writer.writeLine(`${K('INSERT')} ${v} ${K('INTO TABLE')} ${t}.`); + return; + } + case 'Read': { + const t = printExpression(stmt.table, writer); + const bind = + stmt.binding.bindKind === 'into' + ? `${K('INTO')} ${stmt.binding.target}` + : `${K('ASSIGNING')} ${stmt.binding.fieldSymbol}`; + const parts: string[] = [`${K('READ TABLE')} ${t}`]; + if (stmt.index !== undefined) { + parts.push(`${K('INDEX')} ${printExpression(stmt.index, writer)}`); + } + parts.push(bind); + if (stmt.withKey && stmt.withKey.length > 0) { + const kv = stmt.withKey + .map((a) => `${a.name} = ${printExpression(a.value, writer)}`) + .join(' '); + parts.push(`${K('WITH KEY')} ${kv}`); + } + writer.writeLine(parts.join(' ') + '.'); + return; + } + case 'Clear': { + writer.writeLine( + `${K('CLEAR')} ${printExpression(stmt.target, writer)}.`, + ); + return; + } + case 'Exit': + writer.writeLine(`${K('EXIT')}.`); + return; + case 'Continue': + writer.writeLine(`${K('CONTINUE')}.`); + return; + case 'Raw': { + const lines = stmt.source.split(/\r?\n/); + for (const line of lines) { + writer.writeLine(line); + } + return; + } + } +} + +export function printComment(node: Comment, writer: Writer): void { + if (node.style === 'star') { + // Star comments begin at column 1. + const lines = node.text.split(/\r?\n/); + for (const line of lines) { + // Push raw line — bypass indent prefix. + writer.rawLine(`* ${line}`); + } + } else { + const lines = node.text.split(/\r?\n/); + for (const line of lines) { + writer.writeLine(`" ${line}`); + } + } +} + +export function printDataDecl(node: DataDecl, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + const kw = node.classData ? K('CLASS-DATA') : K('DATA'); + const type = printInlineType(node.type, writer); + let line = `${kw} ${node.name} ${K('TYPE')} ${type}`; + if (node.initial !== undefined) { + line += ` ${K('VALUE')} ${printExpression(node.initial, writer)}`; + } + writer.writeLine(`${line}.`); +} + +export function printFieldSymbolDecl( + node: FieldSymbolDecl, + writer: Writer, +): void { + const K = (s: string): string => writer.kw(s); + const type = printInlineType(node.type, writer); + writer.writeLine(`${K('FIELD-SYMBOLS')} ${node.name} ${K('TYPE')} ${type}.`); +} diff --git a/packages/abap-ast/src/printer/print-types.ts b/packages/abap-ast/src/printer/print-types.ts new file mode 100644 index 00000000..328f45f9 --- /dev/null +++ b/packages/abap-ast/src/printer/print-types.ts @@ -0,0 +1,88 @@ +import type { Writer } from './writer'; +import type { TypeRef, EnumType, TypeDef } from '../nodes/types'; + +/** Print a TypeRef as an inline type expression (e.g. 'string', 'i', 'zcl_foo'). */ +export function printInlineType(type: TypeRef, writer: Writer): string { + switch (type.kind) { + case 'BuiltinType': { + const parts: string[] = [type.name]; + if (type.length !== undefined) { + parts.push(`${writer.kw('LENGTH')} ${type.length}`); + } + if (type.decimals !== undefined) { + parts.push(`${writer.kw('DECIMALS')} ${type.decimals}`); + } + return parts.join(' '); + } + case 'NamedTypeRef': + return type.name; + case 'TableType': + throw new Error( + 'printInlineType: TableType cannot be used as an inline type reference', + ); + case 'StructureType': + throw new Error( + 'printInlineType: StructureType cannot be used as an inline type reference', + ); + } +} + +/** Print a TYPES top-level declaration. */ +export function printTypeDef(node: TypeDef, writer: Writer): void { + const K = (s: string): string => writer.kw(s); + const t: TypeRef | EnumType = node.type; + if (t.kind === 'StructureType') { + writer.writeLine(`${K('TYPES')}: ${K('BEGIN OF')} ${node.name},`); + writer.indent(); + const maxName = Math.max(...t.fields.map((f) => f.name.length)); + t.fields.forEach((f) => { + const pad = ' '.repeat(maxName - f.name.length); + writer.writeLine( + `${f.name}${pad} ${K('TYPE')} ${printInlineType(f.type, writer)},`, + ); + }); + writer.dedent(); + writer.writeLine(`${K('END OF')} ${node.name}.`); + return; + } + if (t.kind === 'TableType') { + const kindKw = + t.tableKind === 'standard' + ? K('STANDARD') + : t.tableKind === 'sorted' + ? K('SORTED') + : K('HASHED'); + const row = printInlineType(t.rowType, writer); + let key: string; + if (t.keyFields && t.keyFields.length > 0) { + const uniq = t.uniqueness === 'unique' ? K('UNIQUE') : K('NON-UNIQUE'); + key = `${K('WITH')} ${uniq} ${K('KEY')} ${t.keyFields.join(' ')}`; + } else { + key = `${K('WITH')} ${K('DEFAULT KEY')}`; + } + writer.writeLine( + `${K('TYPES')} ${node.name} ${K('TYPE')} ${kindKw} ${K('TABLE OF')} ${row} ${key}.`, + ); + return; + } + if (t.kind === 'EnumType') { + writer.writeLine( + `${K('TYPES')}: ${K('BEGIN OF ENUM')} ${node.name} ${K('BASE TYPE')} ${printInlineType(t.baseType, writer)},`, + ); + writer.indent(); + const maxName = Math.max(...t.members.map((m) => m.name.length)); + t.members.forEach((m) => { + const pad = ' '.repeat(maxName - m.name.length); + const val = + typeof m.value === 'number' ? String(m.value) : `'${m.value}'`; + writer.writeLine(`${m.name}${pad} ${K('VALUE')} ${val},`); + }); + writer.dedent(); + writer.writeLine(`${K('END OF ENUM')} ${node.name}.`); + return; + } + // BuiltinType or NamedTypeRef + writer.writeLine( + `${K('TYPES')} ${node.name} ${K('TYPE')} ${printInlineType(t, writer)}.`, + ); +} diff --git a/packages/abap-ast/src/printer/writer.ts b/packages/abap-ast/src/printer/writer.ts new file mode 100644 index 00000000..8e1913a1 --- /dev/null +++ b/packages/abap-ast/src/printer/writer.ts @@ -0,0 +1,71 @@ +import type { ResolvedPrintOptions } from './options'; + +/** Tiny indentation-aware string builder. */ +export class Writer { + private readonly lines: string[] = []; + private level = 0; + private readonly opts: ResolvedPrintOptions; + + constructor(opts: ResolvedPrintOptions) { + this.opts = opts; + } + + get options(): ResolvedPrintOptions { + return this.opts; + } + + indent(): void { + this.level += 1; + } + + dedent(): void { + if (this.level === 0) { + throw new Error('Writer: cannot dedent below 0'); + } + this.level -= 1; + } + + /** Current indent prefix. */ + prefix(): string { + return ' '.repeat(this.level * this.opts.indent); + } + + /** Write a full line with current indent. Empty string = blank line (no whitespace). */ + writeLine(s = ''): void { + if (s.length === 0) { + this.lines.push(''); + } else { + this.lines.push(this.prefix() + s); + } + } + + /** Append text to the most recent line (no newline). Starts a new indented line if none exists. */ + write(s: string): void { + if (this.lines.length === 0) { + this.lines.push(this.prefix() + s); + } else { + this.lines[this.lines.length - 1] += s; + } + } + + /** Emit a blank line. */ + blank(): void { + this.lines.push(''); + } + + /** Emit a line with no indent prefix (used by star comments, which must begin at column 1). */ + rawLine(s: string): void { + this.lines.push(s); + } + + /** Transform a keyword according to keywordCase. */ + kw(word: string): string { + return this.opts.keywordCase === 'lower' + ? word.toLowerCase() + : word.toUpperCase(); + } + + toString(): string { + return this.lines.join(this.opts.eol); + } +} diff --git a/packages/abap-ast/tests/nodes.test.ts b/packages/abap-ast/tests/nodes.test.ts new file mode 100644 index 00000000..fd17cc18 --- /dev/null +++ b/packages/abap-ast/tests/nodes.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from 'vitest'; +import { + AbapAstError, + append, + assign, + attributeDef, + binOp, + builtinType, + call, + cast, + classDef, + clear, + comment, + constantDecl, + constructorExpr, + continueStmt, + dataDecl, + enumType, + eventDef, + exit, + fieldSymbolDecl, + identifierExpr, + ifStmt, + insert, + interfaceDef, + literal, + localClassDef, + loop, + methodCallExpr, + methodDef, + methodImpl, + methodParam, + namedTypeRef, + raise, + raw, + read, + returnStmt, + section, + stringTemplate, + structureType, + tableType, + tryStmt, + typeDef, +} from '../src/nodes'; + +describe('base / comment', () => { + it('builds a line comment by default', () => { + const c = comment({ text: 'hello' }); + expect(c).toMatchObject({ kind: 'Comment', text: 'hello', style: 'line' }); + }); + it('accepts style star', () => { + expect(comment({ text: 'x', style: 'star' }).style).toBe('star'); + }); +}); + +describe('types', () => { + it('builtinType returns tagged node', () => { + const t = builtinType({ name: 'string' }); + expect(t).toMatchObject({ kind: 'BuiltinType', name: 'string' }); + }); + it('builtinType rejects unknown name', () => { + expect(() => + builtinType({ name: 'nope' as unknown as 'string' }), + ).toThrowError(AbapAstError); + }); + it('namedTypeRef requires a name', () => { + expect(() => namedTypeRef({ name: '' })).toThrowError(/name/); + }); + it('namedTypeRef builds', () => { + expect(namedTypeRef({ name: 'zif_foo=>ty' }).name).toBe('zif_foo=>ty'); + }); + it('tableType defaults to standard', () => { + const t = tableType({ rowType: builtinType({ name: 'i' }) }); + expect(t.tableKind).toBe('standard'); + expect(t.kind).toBe('TableType'); + }); + it('structureType requires at least one field', () => { + expect(() => structureType({ fields: [] })).toThrowError( + /at least one field/, + ); + }); + it('structureType builds', () => { + const s = structureType({ + fields: [{ name: 'id', type: builtinType({ name: 'i' }) }], + }); + expect(s.kind).toBe('StructureType'); + expect(s.fields).toHaveLength(1); + }); + it('enumType requires members', () => { + expect(() => + enumType({ baseType: builtinType({ name: 'i' }), members: [] }), + ).toThrowError(/at least one member/); + }); + it('enumType builds', () => { + const e = enumType({ + baseType: builtinType({ name: 'i' }), + members: [{ name: 'A', value: 1 }], + }); + expect(e.kind).toBe('EnumType'); + }); + it('typeDef requires name', () => { + expect(() => + typeDef({ name: '', type: builtinType({ name: 'i' }) }), + ).toThrowError(/name/); + }); + it('typeDef builds', () => { + const td = typeDef({ + name: 'ty_id', + type: builtinType({ name: 'string' }), + }); + expect(td).toMatchObject({ kind: 'TypeDef', name: 'ty_id' }); + }); +}); + +describe('data decls', () => { + it('dataDecl builds', () => { + const d = dataDecl({ + name: 'lv_x', + type: builtinType({ name: 'i' }), + }); + expect(d.kind).toBe('DataDecl'); + }); + it('dataDecl requires name', () => { + expect(() => + dataDecl({ name: '', type: builtinType({ name: 'i' }) }), + ).toThrowError(/name/); + }); + it('constantDecl requires value', () => { + expect(() => + constantDecl({ + name: 'c', + type: builtinType({ name: 'i' }), + value: undefined as unknown as ReturnType, + }), + ).toThrowError(/value/); + }); + it('constantDecl builds', () => { + const c = constantDecl({ + name: 'c_a', + type: builtinType({ name: 'i' }), + value: literal({ literalKind: 'int', value: 1 }), + }); + expect(c.kind).toBe('ConstantDecl'); + }); + it('fieldSymbolDecl requires angle brackets', () => { + expect(() => + fieldSymbolDecl({ name: 'fs_x', type: builtinType({ name: 'i' }) }), + ).toThrowError(/angle brackets/); + }); + it('fieldSymbolDecl builds', () => { + const fs = fieldSymbolDecl({ + name: '', + type: builtinType({ name: 'i' }), + }); + expect(fs.kind).toBe('FieldSymbolDecl'); + }); +}); + +describe('expressions', () => { + it('literal int requires number', () => { + expect(() => + literal({ literalKind: 'int', value: 'nope' as unknown as number }), + ).toThrowError(/number/); + }); + it('literal bool requires boolean', () => { + expect(() => + literal({ literalKind: 'bool', value: 1 as unknown as boolean }), + ).toThrowError(/boolean/); + }); + it('literal string builds', () => { + expect(literal({ literalKind: 'string', value: 'hi' }).kind).toBe( + 'Literal', + ); + }); + it('identifierExpr builds', () => { + expect(identifierExpr({ name: 'lv_x' }).kind).toBe('IdentifierExpr'); + }); + it('constructorExpr builds', () => { + expect( + constructorExpr({ type: namedTypeRef({ name: 'zcl_x' }) }).kind, + ).toBe('ConstructorExpr'); + }); + it('methodCallExpr instance requires receiver', () => { + expect(() => + methodCallExpr({ method: 'm', callKind: 'instance' }), + ).toThrowError(/receiver/); + }); + it('methodCallExpr static builds', () => { + const m = methodCallExpr({ method: 'do', callKind: 'static' }); + expect(m.kind).toBe('MethodCallExpr'); + }); + it('binOp requires all fields', () => { + expect(() => + binOp({ + op: '=', + left: undefined as unknown as ReturnType, + right: literal({ literalKind: 'int', value: 1 }), + }), + ).toThrowError(/left/); + }); + it('binOp builds', () => { + expect( + binOp({ + op: '=', + left: identifierExpr({ name: 'a' }), + right: literal({ literalKind: 'int', value: 1 }), + }).kind, + ).toBe('BinOp'); + }); + it('stringTemplate builds', () => { + const t = stringTemplate({ + parts: [ + { partKind: 'text', text: 'hi ' }, + { partKind: 'expr', expr: identifierExpr({ name: 'lv' }) }, + ], + }); + expect(t.kind).toBe('StringTemplate'); + }); + it('cast builds', () => { + const c = cast({ + type: namedTypeRef({ name: 'zcl_foo' }), + expr: identifierExpr({ name: 'x' }), + }); + expect(c.kind).toBe('Cast'); + }); +}); + +describe('statements', () => { + const idX = identifierExpr({ name: 'lv_x' }); + const n1 = literal({ literalKind: 'int', value: 1 }); + + it('assign builds', () => { + expect(assign({ target: idX, value: n1 }).kind).toBe('Assign'); + }); + it('call static builds', () => { + expect(call({ method: 'do', callKind: 'static' }).kind).toBe('Call'); + }); + it('call instance requires receiver', () => { + expect(() => call({ method: 'do', callKind: 'instance' })).toThrowError( + /receiver/, + ); + }); + it('raise builds', () => { + expect(raise({ exceptionType: namedTypeRef({ name: 'zcx_x' }) }).kind).toBe( + 'Raise', + ); + }); + it('ifStmt builds with elseif/else', () => { + const s = ifStmt({ + condition: binOp({ op: '=', left: idX, right: n1 }), + then: [returnStmt()], + elseIfs: [ + { condition: binOp({ op: '=', left: idX, right: n1 }), body: [exit()] }, + ], + else: [continueStmt()], + }); + expect(s.kind).toBe('If'); + expect(s.elseIfs).toHaveLength(1); + }); + it('loop rejects non-bracket assigning target', () => { + expect(() => + loop({ + table: idX, + binding: { bindKind: 'assigning', fieldSymbol: 'fs_y' }, + body: [], + }), + ).toThrowError(/angle brackets/); + }); + it('loop builds', () => { + const l = loop({ + table: idX, + binding: { bindKind: 'into', target: 'ls_wa' }, + body: [], + }); + expect(l.kind).toBe('Loop'); + }); + it('returnStmt without value', () => { + expect(returnStmt().kind).toBe('Return'); + }); + it('try requires at least one catch', () => { + expect(() => tryStmt({ body: [], catches: [] })).toThrowError(/CATCH/); + }); + it('try builds', () => { + const t = tryStmt({ + body: [returnStmt()], + catches: [ + { + exceptionTypes: [namedTypeRef({ name: 'zcx_x' })], + body: [exit()], + }, + ], + }); + expect(t.kind).toBe('Try'); + }); + it('append/insert/read/clear/exit/continue/raw build', () => { + expect(append({ value: idX, table: idX }).kind).toBe('Append'); + expect(insert({ value: idX, table: idX }).kind).toBe('Insert'); + expect( + read({ + table: idX, + binding: { bindKind: 'into', target: 'ls_wa' }, + }).kind, + ).toBe('Read'); + expect(clear({ target: idX }).kind).toBe('Clear'); + expect(exit().kind).toBe('Exit'); + expect(continueStmt().kind).toBe('Continue'); + expect(raw({ source: 'WRITE /.' }).kind).toBe('Raw'); + }); + it('raw rejects empty source', () => { + expect(() => raw({ source: '' })).toThrowError(/source/); + }); +}); + +describe('members', () => { + const i = builtinType({ name: 'i' }); + + it('methodParam rejects optional RETURNING', () => { + expect(() => + methodParam({ + paramKind: 'returning', + name: 'rv', + typeRef: i, + optional: true, + }), + ).toThrowError(/optional/); + }); + it('methodParam rejects RETURNING with default', () => { + expect(() => + methodParam({ + paramKind: 'returning', + name: 'rv', + typeRef: i, + default: literal({ literalKind: 'int', value: 0 }), + }), + ).toThrowError(/default/); + }); + it('methodParam builds', () => { + expect( + methodParam({ paramKind: 'importing', name: 'iv', typeRef: i }).kind, + ).toBe('MethodParam'); + }); + it('methodDef rejects returning + exporting', () => { + expect(() => + methodDef({ + name: 'm', + visibility: 'public', + params: [ + methodParam({ paramKind: 'returning', name: 'rv', typeRef: i }), + methodParam({ paramKind: 'exporting', name: 'ev', typeRef: i }), + ], + }), + ).toThrowError(/RETURNING/); + }); + it('methodDef rejects multiple returning', () => { + expect(() => + methodDef({ + name: 'm', + visibility: 'public', + params: [ + methodParam({ paramKind: 'returning', name: 'rv1', typeRef: i }), + methodParam({ paramKind: 'returning', name: 'rv2', typeRef: i }), + ], + }), + ).toThrowError(/at most one RETURNING/); + }); + it('methodDef builds', () => { + expect(methodDef({ name: 'm', visibility: 'public' }).kind).toBe( + 'MethodDef', + ); + }); + it('methodImpl builds', () => { + expect(methodImpl({ name: 'm', body: [exit()] }).kind).toBe('MethodImpl'); + }); + it('eventDef builds', () => { + expect(eventDef({ name: 'e', visibility: 'public' }).kind).toBe('EventDef'); + }); + it('attributeDef builds', () => { + expect( + attributeDef({ name: 'a', type: i, visibility: 'public' }).kind, + ).toBe('AttributeDef'); + }); +}); + +describe('class / interface', () => { + const i = builtinType({ name: 'i' }); + + it('section rejects mismatched visibility', () => { + expect(() => + section({ + visibility: 'public', + members: [methodDef({ name: 'm', visibility: 'private' })], + }), + ).toThrowError(/visibility/); + }); + it('section builds', () => { + expect( + section({ + visibility: 'public', + members: [methodDef({ name: 'm', visibility: 'public' })], + }).kind, + ).toBe('Section'); + }); + it('classDef rejects final + abstract', () => { + expect(() => + classDef({ name: 'zcl_x', isFinal: true, isAbstract: true }), + ).toThrowError(/FINAL and ABSTRACT/); + }); + it('classDef builds', () => { + expect(classDef({ name: 'zcl_x' }).kind).toBe('ClassDef'); + }); + it('localClassDef builds with local=true', () => { + expect(localClassDef({ name: 'lcl_x' }).local).toBe(true); + }); + it('interfaceDef rejects non-public methods', () => { + expect(() => + interfaceDef({ + name: 'zif', + members: [methodDef({ name: 'm', visibility: 'private' })], + }), + ).toThrowError(/public/); + }); + it('interfaceDef builds', () => { + expect(interfaceDef({ name: 'zif' }).kind).toBe('InterfaceDef'); + }); + + it('composite: ClassDef with TypeDef + AttributeDef + MethodDef containing LOOP', () => { + const tyId = typeDef({ name: 'ty_id', type: i }); + const attr = attributeDef({ + name: 'mv_count', + type: i, + visibility: 'private', + }); + const method = methodDef({ + name: 'iterate', + visibility: 'public', + params: [ + methodParam({ + paramKind: 'importing', + name: 'it_items', + typeRef: tableType({ rowType: i }), + }), + ], + }); + const impl = methodImpl({ + name: 'iterate', + body: [ + loop({ + table: identifierExpr({ name: 'it_items' }), + binding: { bindKind: 'assigning', fieldSymbol: '' }, + body: [ + assign({ + target: identifierExpr({ name: 'mv_count' }), + value: binOp({ + op: '+', + left: identifierExpr({ name: 'mv_count' }), + right: literal({ literalKind: 'int', value: 1 }), + }), + }), + ], + }), + ], + }); + const cls = classDef({ + name: 'zcl_foo', + isFinal: true, + sections: [ + section({ visibility: 'public', members: [method] }), + section({ visibility: 'private', members: [tyId, attr] }), + ], + implementations: [impl], + }); + expect(cls.kind).toBe('ClassDef'); + expect(cls.sections).toHaveLength(2); + expect(cls.implementations[0]?.body[0]?.kind).toBe('Loop'); + }); +}); diff --git a/packages/abap-ast/tests/printer.test.ts b/packages/abap-ast/tests/printer.test.ts new file mode 100644 index 00000000..c88bcb86 --- /dev/null +++ b/packages/abap-ast/tests/printer.test.ts @@ -0,0 +1,861 @@ +import { describe, it, expect } from 'vitest'; +import { + print, + // nodes — types + builtinType, + namedTypeRef, + tableType, + structureType, + enumType, + typeDef, + // nodes — data + dataDecl, + constantDecl, + fieldSymbolDecl, + // nodes — expressions + literal, + identifierExpr, + constructorExpr, + methodCallExpr, + binOp, + stringTemplate, + cast, + // nodes — statements + assign, + call, + raise, + ifStmt, + loop, + returnStmt, + tryStmt, + append, + insert, + read, + clear, + exit, + continueStmt, + raw, + comment, + // nodes — members + methodParam, + methodDef, + methodImpl, + eventDef, + attributeDef, + // nodes — class / interface + section, + classDef, + localClassDef, + interfaceDef, +} from '../src'; + +describe('printer — types', () => { + it('prints a simple builtin TypeDef', () => { + const node = typeDef({ name: 'ty_num', type: builtinType({ name: 'i' }) }); + expect(print(node)).toMatchInlineSnapshot(`"TYPES ty_num TYPE i."`); + }); + + it('prints a named-type alias', () => { + const node = typeDef({ + name: 'ty_key', + type: namedTypeRef({ name: 'zif_core=>ty_key' }), + }); + expect(print(node)).toMatchInlineSnapshot( + `"TYPES ty_key TYPE zif_core=>ty_key."`, + ); + }); + + it('prints a structure TypeDef with aligned fields', () => { + const node = typeDef({ + name: 'ty_row', + type: structureType({ + fields: [ + { name: 'id', type: builtinType({ name: 'string' }) }, + { name: 'description', type: builtinType({ name: 'string' }) }, + { name: 'count', type: builtinType({ name: 'i' }) }, + ], + }), + }); + expect(print(node)).toMatchInlineSnapshot(` + "TYPES: BEGIN OF ty_row, + id TYPE string, + description TYPE string, + count TYPE i, + END OF ty_row." + `); + }); + + it('prints a table TypeDef (standard, default key)', () => { + const node = typeDef({ + name: 'ty_list', + type: tableType({ rowType: namedTypeRef({ name: 'ty_row' }) }), + }); + expect(print(node)).toMatchInlineSnapshot( + `"TYPES ty_list TYPE STANDARD TABLE OF ty_row WITH DEFAULT KEY."`, + ); + }); + + it('prints a sorted table with unique key', () => { + const node = typeDef({ + name: 'ty_sorted', + type: tableType({ + rowType: namedTypeRef({ name: 'ty_row' }), + tableKind: 'sorted', + uniqueness: 'unique', + keyFields: ['id'], + }), + }); + expect(print(node)).toMatchInlineSnapshot( + `"TYPES ty_sorted TYPE SORTED TABLE OF ty_row WITH UNIQUE KEY id."`, + ); + }); + + it('prints an enum TypeDef', () => { + const node = typeDef({ + name: 'ty_weekday', + type: enumType({ + baseType: builtinType({ name: 'i' }), + members: [ + { name: 'monday', value: 1 }, + { name: 'tuesday', value: 2 }, + { name: 'wednesday', value: 3 }, + ], + }), + }); + expect(print(node)).toMatchInlineSnapshot(` + "TYPES: BEGIN OF ENUM ty_weekday BASE TYPE i, + monday VALUE 1, + tuesday VALUE 2, + wednesday VALUE 3, + END OF ENUM ty_weekday." + `); + }); +}); + +describe('printer — expressions (as top-level)', () => { + it('prints a string literal', () => { + expect(print(literal({ literalKind: 'string', value: "it's" }))).toBe( + "'it''s'", + ); + }); + + it('prints int / bool / hex literals', () => { + expect(print(literal({ literalKind: 'int', value: 42 }))).toBe('42'); + expect(print(literal({ literalKind: 'bool', value: true }))).toBe( + 'abap_true', + ); + expect(print(literal({ literalKind: 'hex', value: '0AFF' }))).toBe( + "'0AFF'", + ); + }); + + it('prints an identifier expression', () => { + expect(print(identifierExpr({ name: 'lv_x' }))).toBe('lv_x'); + }); + + it('prints a constructor expression', () => { + const node = constructorExpr({ + type: namedTypeRef({ name: 'zcl_thing' }), + args: [ + { name: 'iv_x', value: literal({ literalKind: 'int', value: 1 }) }, + ], + }); + expect(print(node)).toBe('NEW zcl_thing( iv_x = 1 )'); + }); + + it('prints a static method call', () => { + const node = methodCallExpr({ + receiver: identifierExpr({ name: 'cl_foo' }), + method: 'bar', + callKind: 'static', + args: [], + }); + expect(print(node)).toBe('cl_foo=>bar( )'); + }); + + it('prints an instance method call with named args', () => { + const node = methodCallExpr({ + receiver: identifierExpr({ name: 'ref' }), + method: 'run', + callKind: 'instance', + args: [ + { name: 'iv_a', value: literal({ literalKind: 'int', value: 1 }) }, + { name: 'iv_b', value: literal({ literalKind: 'int', value: 2 }) }, + ], + }); + expect(print(node)).toBe('ref->run( iv_a = 1 iv_b = 2 )'); + }); + + it('prints a binary comparison', () => { + const node = binOp({ + op: '=', + left: identifierExpr({ name: 'lv_x' }), + right: literal({ literalKind: 'int', value: 0 }), + }); + expect(print(node)).toBe('lv_x = 0'); + }); + + it('prints AND/OR with keyword case', () => { + const node = binOp({ + op: 'AND', + left: identifierExpr({ name: 'a' }), + right: identifierExpr({ name: 'b' }), + }); + expect(print(node)).toBe('a AND b'); + }); + + it('prints a string template', () => { + const node = stringTemplate({ + parts: [ + { partKind: 'text', text: 'Hello, ' }, + { partKind: 'expr', expr: identifierExpr({ name: 'iv_name' }) }, + { partKind: 'text', text: '!' }, + ], + }); + expect(print(node)).toBe('|Hello, { iv_name }!|'); + }); + + it('prints a CAST', () => { + const node = cast({ + type: namedTypeRef({ name: 'zcl_x' }), + expr: identifierExpr({ name: 'ref' }), + }); + expect(print(node)).toBe('CAST zcl_x( ref )'); + }); +}); + +describe('printer — statements', () => { + it('Assign', () => { + expect( + print( + assign({ + target: identifierExpr({ name: 'lv_x' }), + value: literal({ literalKind: 'int', value: 1 }), + }), + ), + ).toMatchInlineSnapshot(`"lv_x = 1."`); + }); + + it('Call (static, no args)', () => { + expect( + print( + call({ + receiver: identifierExpr({ name: 'cl_foo' }), + method: 'run', + callKind: 'static', + }), + ), + ).toMatchInlineSnapshot(`"cl_foo=>run( )."`); + }); + + it('Call (instance, named args)', () => { + expect( + print( + call({ + receiver: identifierExpr({ name: 'ref' }), + method: 'do', + callKind: 'instance', + args: [ + { + name: 'iv_a', + value: literal({ literalKind: 'int', value: 1 }), + }, + ], + }), + ), + ).toMatchInlineSnapshot(`"ref->do( iv_a = 1 )."`); + }); + + it('Raise', () => { + expect( + print( + raise({ + exceptionType: namedTypeRef({ name: 'zcx_bad' }), + args: [ + { + name: 'textid', + value: identifierExpr({ name: 'zcx_bad=>ouch' }), + }, + ], + }), + ), + ).toMatchInlineSnapshot( + `"RAISE EXCEPTION NEW zcx_bad( textid = zcx_bad=>ouch )."`, + ); + }); + + it('If / ElseIf / Else', () => { + const node = ifStmt({ + condition: binOp({ + op: '=', + left: identifierExpr({ name: 'lv_x' }), + right: literal({ literalKind: 'int', value: 1 }), + }), + then: [ + assign({ + target: identifierExpr({ name: 'lv_y' }), + value: literal({ literalKind: 'int', value: 10 }), + }), + ], + elseIfs: [ + { + condition: binOp({ + op: '=', + left: identifierExpr({ name: 'lv_x' }), + right: literal({ literalKind: 'int', value: 2 }), + }), + body: [ + assign({ + target: identifierExpr({ name: 'lv_y' }), + value: literal({ literalKind: 'int', value: 20 }), + }), + ], + }, + ], + else: [ + assign({ + target: identifierExpr({ name: 'lv_y' }), + value: literal({ literalKind: 'int', value: 0 }), + }), + ], + }); + expect(print(node)).toMatchInlineSnapshot(` + "IF lv_x = 1. + lv_y = 10. + ELSEIF lv_x = 2. + lv_y = 20. + ELSE. + lv_y = 0. + ENDIF." + `); + }); + + it('Loop with Assign inside', () => { + const node = loop({ + table: identifierExpr({ name: 'lt_rows' }), + binding: { bindKind: 'assigning', fieldSymbol: '' }, + body: [ + assign({ + target: identifierExpr({ name: '-count' }), + value: literal({ literalKind: 'int', value: 0 }), + }), + ], + }); + expect(print(node)).toMatchInlineSnapshot(` + "LOOP AT lt_rows ASSIGNING . + -count = 0. + ENDLOOP." + `); + }); + + it('Return with value', () => { + expect(print(returnStmt({ value: identifierExpr({ name: 'lv_result' }) }))) + .toMatchInlineSnapshot(` + "rv = lv_result. + RETURN." + `); + }); + + it('Return without value', () => { + expect(print(returnStmt())).toMatchInlineSnapshot(`"RETURN."`); + }); + + it('Try / Catch / Cleanup', () => { + const node = tryStmt({ + body: [ + call({ + receiver: identifierExpr({ name: 'cl_x' }), + method: 'run', + callKind: 'static', + }), + ], + catches: [ + { + exceptionTypes: [namedTypeRef({ name: 'zcx_bad' })], + into: 'lx_err', + body: [ + raise({ + exceptionType: namedTypeRef({ name: 'zcx_bad' }), + args: [], + }), + ], + }, + ], + cleanup: [clear({ target: identifierExpr({ name: 'lv_x' }) })], + }); + expect(print(node)).toMatchInlineSnapshot(` + "TRY. + cl_x=>run( ). + CATCH zcx_bad INTO lx_err. + RAISE EXCEPTION NEW zcx_bad( ). + CLEANUP. + CLEAR lv_x. + ENDTRY." + `); + }); + + it('Append / Insert', () => { + expect( + print( + append({ + value: identifierExpr({ name: 'ls_row' }), + table: identifierExpr({ name: 'lt_rows' }), + }), + ), + ).toMatchInlineSnapshot(`"APPEND ls_row TO lt_rows."`); + expect( + print( + insert({ + value: identifierExpr({ name: 'ls_row' }), + table: identifierExpr({ name: 'lt_rows' }), + }), + ), + ).toMatchInlineSnapshot(`"INSERT ls_row INTO TABLE lt_rows."`); + }); + + it('Read with WITH KEY and INTO', () => { + const node = read({ + table: identifierExpr({ name: 'lt_rows' }), + binding: { bindKind: 'into', target: 'ls_row' }, + withKey: [ + { name: 'id', value: literal({ literalKind: 'string', value: 'X' }) }, + ], + }); + expect(print(node)).toMatchInlineSnapshot( + `"READ TABLE lt_rows INTO ls_row WITH KEY id = 'X'."`, + ); + }); + + it('Clear / Exit / Continue', () => { + expect( + print(clear({ target: identifierExpr({ name: 'lv_x' }) })), + ).toMatchInlineSnapshot(`"CLEAR lv_x."`); + expect(print(exit())).toMatchInlineSnapshot(`"EXIT."`); + expect(print(continueStmt())).toMatchInlineSnapshot(`"CONTINUE."`); + }); + + it('Raw', () => { + expect(print(raw({ source: 'WRITE / `hello`.' }))).toMatchInlineSnapshot( + `"WRITE / \`hello\`."`, + ); + }); + + it('Comment (line)', () => { + expect(print(comment({ text: 'hi', style: 'line' }))).toBe('" hi'); + }); + + it('Comment (star)', () => { + expect(print(comment({ text: 'hi', style: 'star' }))).toMatchInlineSnapshot( + `"* hi"`, + ); + }); + + it('DataDecl / FieldSymbolDecl', () => { + expect( + print(dataDecl({ name: 'lv_x', type: builtinType({ name: 'i' }) })), + ).toMatchInlineSnapshot(`"DATA lv_x TYPE i."`); + expect( + print( + fieldSymbolDecl({ + name: '', + type: namedTypeRef({ name: 'ty_row' }), + }), + ), + ).toMatchInlineSnapshot(`"FIELD-SYMBOLS TYPE ty_row."`); + }); + + it('ConstantDecl', () => { + expect( + print( + constantDecl({ + name: 'c_max', + type: builtinType({ name: 'i' }), + value: literal({ literalKind: 'int', value: 10 }), + }), + ), + ).toMatchInlineSnapshot(`"CONSTANTS c_max TYPE i VALUE 10."`); + }); +}); + +describe('printer — members', () => { + it('AttributeDef (instance)', () => { + expect( + print( + attributeDef({ + name: 'mv_x', + type: builtinType({ name: 'string' }), + visibility: 'private', + }), + ), + ).toMatchInlineSnapshot(`"DATA mv_x TYPE string."`); + }); + + it('AttributeDef (class-data, read-only, initial)', () => { + expect( + print( + attributeDef({ + name: 'gv_n', + type: builtinType({ name: 'i' }), + visibility: 'public', + classData: true, + readOnly: true, + initial: literal({ literalKind: 'int', value: 0 }), + }), + ), + ).toMatchInlineSnapshot(`"CLASS-DATA gv_n TYPE i READ-ONLY VALUE 0."`); + }); + + it('EventDef', () => { + expect( + print(eventDef({ name: 'changed', visibility: 'public' })), + ).toMatchInlineSnapshot(`"EVENTS changed."`); + }); + + it('MethodDef — no params, no raising', () => { + expect( + print(methodDef({ name: 'ping', visibility: 'public' })), + ).toMatchInlineSnapshot(`"METHODS ping."`); + }); + + it('MethodDef — importing + returning + raising', () => { + const node = methodDef({ + name: 'greet', + visibility: 'public', + params: [ + methodParam({ + paramKind: 'importing', + name: 'iv_name', + typeRef: builtinType({ name: 'string' }), + }), + methodParam({ + paramKind: 'returning', + name: 'rv_msg', + typeRef: builtinType({ name: 'string' }), + }), + ], + raising: [namedTypeRef({ name: 'zcx_greet_error' })], + }); + expect(print(node)).toMatchInlineSnapshot(` + "METHODS greet + IMPORTING iv_name TYPE string + RETURNING VALUE(rv_msg) TYPE string + RAISING zcx_greet_error." + `); + }); + + it('MethodImpl', () => { + const node = methodImpl({ + name: 'greet', + body: [ + assign({ + target: identifierExpr({ name: 'rv_msg' }), + value: stringTemplate({ + parts: [ + { partKind: 'text', text: 'Hi, ' }, + { partKind: 'expr', expr: identifierExpr({ name: 'iv_name' }) }, + ], + }), + }), + returnStmt(), + ], + }); + expect(print(node)).toMatchInlineSnapshot(` + "METHOD greet. + rv_msg = |Hi, { iv_name }|. + RETURN. + ENDMETHOD." + `); + }); +}); + +describe('printer — interface', () => { + it('prints an interface with a method and a type', () => { + const node = interfaceDef({ + name: 'zif_greeter', + members: [ + typeDef({ name: 'ty_name', type: builtinType({ name: 'string' }) }), + methodDef({ + name: 'greet', + visibility: 'public', + params: [ + methodParam({ + paramKind: 'importing', + name: 'iv_name', + typeRef: namedTypeRef({ name: 'ty_name' }), + }), + methodParam({ + paramKind: 'returning', + name: 'rv_msg', + typeRef: builtinType({ name: 'string' }), + }), + ], + }), + ], + }); + expect(print(node)).toMatchInlineSnapshot(` + "INTERFACE zif_greeter PUBLIC. + TYPES ty_name TYPE string. + METHODS greet + IMPORTING iv_name TYPE ty_name + RETURNING VALUE(rv_msg) TYPE string. + ENDINTERFACE." + `); + }); +}); + +describe('printer — Section (error)', () => { + it('throws for Section as top-level', () => { + const sec = section({ visibility: 'public' }); + expect(() => print(sec)).toThrow(/Section/); + }); + + it('throws for MethodParam as top-level', () => { + const p = methodParam({ + paramKind: 'importing', + name: 'iv_x', + typeRef: builtinType({ name: 'string' }), + }); + expect(() => print(p)).toThrow(/MethodParam/); + }); + + it('throws for TableType as top-level', () => { + const tt = tableType({ rowType: namedTypeRef({ name: 'ty_row' }) }); + expect(() => print(tt)).toThrow(/TableType/); + }); +}); + +describe('printer — LocalClassDef', () => { + it('prints a local exception class definition', () => { + const node = localClassDef({ + name: 'lcx_bad', + superclass: 'cx_static_check', + isFinal: true, + sections: [section({ visibility: 'public' })], + }); + expect(print(node)).toMatchInlineSnapshot(` + "CLASS lcx_bad DEFINITION FINAL INHERITING FROM cx_static_check. + PUBLIC SECTION. + ENDCLASS." + `); + }); +}); + +describe('printer — ClassDef (composite)', () => { + const composite = buildCompositeClass(); + + it('prints the composite class snapshot', () => { + expect(print(composite)).toMatchInlineSnapshot(` + "CLASS zcl_greeter DEFINITION PUBLIC FINAL CREATE PUBLIC. + PUBLIC SECTION. + TYPES: BEGIN OF ty_row, + id TYPE string, + description TYPE string, + count TYPE i, + END OF ty_row. + TYPES ty_rows TYPE STANDARD TABLE OF ty_row WITH DEFAULT KEY. + TYPES: BEGIN OF ENUM ty_mode BASE TYPE i, + quiet VALUE 0, + loud VALUE 1, + END OF ENUM ty_mode. + DATA mv_prefix TYPE string. + METHODS greet + IMPORTING iv_name TYPE string + RETURNING VALUE(rv_msg) TYPE string + RAISING zcx_greet_error. + ENDCLASS. + + CLASS zcl_greeter IMPLEMENTATION. + METHOD greet. + DATA lt_rows TYPE ty_rows. + cl_log=>write( iv_text = iv_name ). + rv_msg = |Hi, { iv_name }!|. + IF iv_name = ''. + RAISE EXCEPTION NEW zcx_greet_error( ). + ELSE. + rv_msg = |Hi, { iv_name }!|. + ENDIF. + LOOP AT lt_rows ASSIGNING . + -count = 0. + ENDLOOP. + rv = rv_msg. + RETURN. + ENDMETHOD. + ENDCLASS." + `); + }); + + it('is deterministic across calls', () => { + const a = print(composite); + const b = print(composite); + expect(a).toBe(b); + }); + + it('prints the local exception class', () => { + const lcx = localClassDef({ + name: 'lcx_greet_error', + superclass: 'cx_static_check', + isFinal: true, + sections: [section({ visibility: 'public' })], + }); + expect(print(lcx)).toMatchInlineSnapshot(` + "CLASS lcx_greet_error DEFINITION FINAL INHERITING FROM cx_static_check. + PUBLIC SECTION. + ENDCLASS." + `); + }); +}); + +describe('printer — keyword case', () => { + it('lowercases keywords when keywordCase = "lower"', () => { + const node = typeDef({ + name: 'ty_list', + type: tableType({ rowType: namedTypeRef({ name: 'ty_row' }) }), + }); + expect(print(node, { keywordCase: 'lower' })).toMatchInlineSnapshot( + `"types ty_list type standard table of ty_row with default key."`, + ); + }); + + it('lowercases keywords in a class', () => { + const node = classDef({ + name: 'zcl_x', + isFinal: true, + sections: [ + section({ + visibility: 'public', + members: [methodDef({ name: 'ping', visibility: 'public' })], + }), + ], + implementations: [methodImpl({ name: 'ping', body: [returnStmt()] })], + }); + const out = print(node, { keywordCase: 'lower' }); + expect(out).toContain('class zcl_x definition public final create public.'); + expect(out).toContain('public section.'); + expect(out).toContain('methods ping.'); + expect(out).toContain('endclass.'); + expect(out).toContain('method ping.'); + expect(out).toContain('return.'); + expect(out).toContain('endmethod.'); + }); +}); + +function buildCompositeClass() { + const tyRow = typeDef({ + name: 'ty_row', + type: structureType({ + fields: [ + { name: 'id', type: builtinType({ name: 'string' }) }, + { name: 'description', type: builtinType({ name: 'string' }) }, + { name: 'count', type: builtinType({ name: 'i' }) }, + ], + }), + }); + const tyRows = typeDef({ + name: 'ty_rows', + type: tableType({ rowType: namedTypeRef({ name: 'ty_row' }) }), + }); + const tyMode = typeDef({ + name: 'ty_mode', + type: enumType({ + baseType: builtinType({ name: 'i' }), + members: [ + { name: 'quiet', value: 0 }, + { name: 'loud', value: 1 }, + ], + }), + }); + const attr = attributeDef({ + name: 'mv_prefix', + type: builtinType({ name: 'string' }), + visibility: 'public', + }); + const greet = methodDef({ + name: 'greet', + visibility: 'public', + params: [ + methodParam({ + paramKind: 'importing', + name: 'iv_name', + typeRef: builtinType({ name: 'string' }), + }), + methodParam({ + paramKind: 'returning', + name: 'rv_msg', + typeRef: builtinType({ name: 'string' }), + }), + ], + raising: [namedTypeRef({ name: 'zcx_greet_error' })], + }); + const greetImpl = methodImpl({ + name: 'greet', + body: [ + dataDecl({ name: 'lt_rows', type: namedTypeRef({ name: 'ty_rows' }) }), + call({ + receiver: identifierExpr({ name: 'cl_log' }), + method: 'write', + callKind: 'static', + args: [{ name: 'iv_text', value: identifierExpr({ name: 'iv_name' }) }], + }), + assign({ + target: identifierExpr({ name: 'rv_msg' }), + value: stringTemplate({ + parts: [ + { partKind: 'text', text: 'Hi, ' }, + { partKind: 'expr', expr: identifierExpr({ name: 'iv_name' }) }, + { partKind: 'text', text: '!' }, + ], + }), + }), + ifStmt({ + condition: binOp({ + op: '=', + left: identifierExpr({ name: 'iv_name' }), + right: literal({ literalKind: 'string', value: '' }), + }), + then: [ + raise({ + exceptionType: namedTypeRef({ name: 'zcx_greet_error' }), + args: [], + }), + ], + else: [ + assign({ + target: identifierExpr({ name: 'rv_msg' }), + value: stringTemplate({ + parts: [ + { partKind: 'text', text: 'Hi, ' }, + { + partKind: 'expr', + expr: identifierExpr({ name: 'iv_name' }), + }, + { partKind: 'text', text: '!' }, + ], + }), + }), + ], + }), + loop({ + table: identifierExpr({ name: 'lt_rows' }), + binding: { bindKind: 'assigning', fieldSymbol: '' }, + body: [ + assign({ + target: identifierExpr({ name: '-count' }), + value: literal({ literalKind: 'int', value: 0 }), + }), + ], + }), + returnStmt({ value: identifierExpr({ name: 'rv_msg' }) }), + ], + }); + return classDef({ + name: 'zcl_greeter', + isFinal: true, + sections: [ + section({ + visibility: 'public', + members: [tyRow, tyRows, tyMode, attr, greet], + }), + ], + implementations: [greetImpl], + }); +} diff --git a/packages/abap-ast/tsconfig.json b/packages/abap-ast/tsconfig.json new file mode 100644 index 00000000..c2104f6b --- /dev/null +++ b/packages/abap-ast/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/abap-ast/tsdown.config.ts b/packages/abap-ast/tsdown.config.ts new file mode 100644 index 00000000..ab43cf84 --- /dev/null +++ b/packages/abap-ast/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; +import baseConfig from '../../tsdown.config.ts'; + +export default defineConfig({ + ...baseConfig, + entry: ['src/index.ts'], +}); diff --git a/packages/abap-ast/vitest.config.ts b/packages/abap-ast/vitest.config.ts new file mode 100644 index 00000000..8e730d50 --- /dev/null +++ b/packages/abap-ast/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/packages/openai-codegen/eslint.config.js b/packages/openai-codegen/eslint.config.js new file mode 100644 index 00000000..b7f62772 --- /dev/null +++ b/packages/openai-codegen/eslint.config.js @@ -0,0 +1,3 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [...baseConfig]; diff --git a/packages/openai-codegen/package.json b/packages/openai-codegen/package.json new file mode 100644 index 00000000..6b9641f3 --- /dev/null +++ b/packages/openai-codegen/package.json @@ -0,0 +1,42 @@ +{ + "name": "@abapify/openai-codegen", + "publishConfig": { + "access": "public" + }, + "version": "0.1.0", + "description": "Deterministic OpenAPI → ABAP client code generator. Emits a single zero-dependency ABAP class per OpenAPI spec, targeting SAP Cloud / on-prem profiles, packaged as abapGit or gCTS.", + "type": "module", + "types": "./dist/index.d.mts", + "bin": { + "openai-codegen": "./dist/cli.mjs" + }, + "exports": { + ".": "./dist/index.mjs", + "./cli": "./dist/cli.mjs", + "./package.json": "./package.json" + }, + "dependencies": { + "@abapify/abap-ast": "workspace:*", + "@apidevtools/swagger-parser": "^10.1.0", + "commander": "^11.1.0", + "fast-xml-parser": "^5.5.0", + "yaml": "^2.5.0" + }, + "files": [ + "dist", + "README.md" + ], + "keywords": [ + "openapi", + "swagger", + "abap", + "codegen", + "sap", + "steampunk" + ], + "author": "abapify", + "license": "MIT", + "devDependencies": { + "@abaplint/core": "^2.118.12" + } +} diff --git a/packages/openai-codegen/src/cli.ts b/packages/openai-codegen/src/cli.ts new file mode 100644 index 00000000..f2671e2e --- /dev/null +++ b/packages/openai-codegen/src/cli.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { resolve } from 'node:path'; +import { generate } from './generate'; +import type { OutputFormat } from './format/index'; +import type { TargetProfileId } from './profiles/index'; + +/** Parse --format=abapgit,gcts into an array with strict validation. */ +function parseFormats(raw: string): OutputFormat[] { + const allowed: OutputFormat[] = ['abapgit', 'gcts']; + const parts = raw + .split(',') + .map((p) => p.trim().toLowerCase()) + .filter((p) => p.length > 0); + if (parts.length === 0) { + throw new Error('--format must contain at least one value'); + } + for (const p of parts) { + if (!allowed.includes(p as OutputFormat)) { + throw new Error( + `invalid --format '${p}'. expected one of: ${allowed.join(', ')}`, + ); + } + } + // Deduplicate, preserving order. + return Array.from(new Set(parts)) as OutputFormat[]; +} + +function parseTarget(raw: string): TargetProfileId { + const allowed: TargetProfileId[] = [ + 's4-cloud', + 's4-onprem-modern', + 'on-prem-classic', + ]; + const normalized = raw.trim().toLowerCase() as TargetProfileId; + if (!allowed.includes(normalized)) { + throw new Error( + `invalid --target '${raw}'. expected one of: ${allowed.join(', ')}`, + ); + } + return normalized; +} + +const program = new Command(); + +program + .name('openai-codegen') + .description( + 'Deterministic OpenAPI → ABAP client code generator. Emits a single zero-dependency ABAP class per spec.', + ) + .version('0.1.0') + .requiredOption( + '-i, --input ', + 'path or URL to an OpenAPI spec (JSON or YAML)', + ) + .requiredOption('-o, --out ', 'output directory') + .option( + '-t, --target ', + "target SAP system profile ('s4-cloud' is the only one implemented in v1)", + 's4-cloud', + ) + .option( + '-f, --format ', + "comma-separated output layouts: 'abapgit', 'gcts', or 'abapgit,gcts'", + 'abapgit', + ) + .requiredOption( + '-c, --class-name ', + 'ABAP class name, e.g. ZCL_PETSTORE3_CLIENT', + ) + .requiredOption( + '-p, --type-prefix ', + "lower-case ABAP type prefix (without 'ty_' / trailing underscore), e.g. 'ps3'", + ) + .option( + '-d, --description ', + 'short description used in the .clas.xml DESCRIPT', + ) + .action(async (rawOpts: Record) => { + try { + const target = parseTarget(rawOpts['target'] ?? 's4-cloud'); + const formats = parseFormats(rawOpts['format'] ?? 'abapgit'); + const outDir = resolve(process.cwd(), rawOpts['out']!); + + for (const format of formats) { + const formatOutDir = + formats.length === 1 ? outDir : resolve(outDir, format); + const result = await generate({ + input: rawOpts['input']!, + outDir: formatOutDir, + target, + format, + className: rawOpts['className']!, + typePrefix: rawOpts['typePrefix']!, + description: rawOpts['description'], + }); + process.stdout.write( + `[openai-codegen] ${format}: wrote ${result.files.length} files ` + + `(${result.typeCount} types, ${result.operationCount} operations) ` + + `→ ${formatOutDir}\n`, + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[openai-codegen] error: ${msg}\n`); + process.exit(1); + } + }); + +program.parseAsync(process.argv).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[openai-codegen] fatal: ${msg}\n`); + process.exit(1); +}); diff --git a/packages/openai-codegen/src/emit/assemble.ts b/packages/openai-codegen/src/emit/assemble.ts new file mode 100644 index 00000000..813d6372 --- /dev/null +++ b/packages/openai-codegen/src/emit/assemble.ts @@ -0,0 +1,479 @@ +import { + attributeDef, + builtinType, + classDef, + methodDef, + methodImpl, + methodParam, + namedTypeRef, + raw, + section, + tableType, + typeDef, + type AttributeDef, + type ClassDef, + type ConstantDecl, + type LocalClassDef, + type MethodDef, + type MethodImpl, + type MethodParam, + type SectionMember, + type Statement, + type TypeDef, + type TypeRef, +} from '@abapify/abap-ast'; +import type { NormalizedSpec } from '../oas/types'; +import type { TypePlan } from '../types/plan'; +import type { TargetProfile } from '../profiles/types'; +import { assertClassAllowed } from '../profiles/registry'; +import type { CloudRuntime } from '../runtime/s4-cloud/index'; +import { emitTypeSection } from '../types/emit'; +import { makeNameAllocator } from '../types/naming'; +import { buildImportingParams } from './parameters'; +import { buildRaising, buildReturning } from './responses'; +import { buildOperationBody } from './operation-body'; +import { buildExceptionClass } from './exception-class'; +import { methodNameFor, exceptionClassNameFor } from './identifiers'; +import { emitSecuritySupport } from './security'; +import { emitServerConstants, emitServerCtorParams } from './server'; + +export interface EmitClientOptions { + className: string; + typePrefix: string; +} + +export interface EmittedClient { + /** The generated global class (definition + implementation). */ + readonly class: ClassDef; + /** Extras emitted alongside the main class (e.g. local exception class). */ + readonly extras: readonly LocalClassDef[]; +} + +/** Rebuild an AttributeDef with a new visibility (AST objects are frozen). */ +function reVisibility( + a: AttributeDef, + v: 'public' | 'protected' | 'private', +): AttributeDef { + return attributeDef({ + name: a.name, + type: a.type, + visibility: v, + classData: a.classData, + readOnly: a.readOnly, + initial: a.initial, + }); +} + +/** Hoist a TypeRef that cannot be used inline (TableType / StructureType) into + * a named typedef; returns a NamedTypeRef pointing at it. BuiltinType and + * NamedTypeRef are returned unchanged. */ +function makeTypeHoister( + extraTypes: TypeDef[], + usedNames: Set, +): (ref: TypeRef, hint: string) => TypeRef { + return function hoist(ref, hint) { + if (ref.kind === 'BuiltinType' || ref.kind === 'NamedTypeRef') { + return ref; + } + // For a TableType whose row is a NamedTypeRef, build ty__tab. + let baseName = `ty_${hint}`; + if (ref.kind === 'TableType') baseName = `${baseName}_tab`; + let name = baseName; + let i = 2; + while (usedNames.has(name)) { + name = `${baseName}_${i}`; + i += 1; + } + usedNames.add(name); + if (ref.kind === 'TableType') { + extraTypes.push( + typeDef({ + name, + type: tableType({ + rowType: ref.rowType, + tableKind: ref.tableKind, + uniqueness: ref.uniqueness, + keyFields: ref.keyFields, + }), + }), + ); + } else { + extraTypes.push(typeDef({ name, type: ref })); + } + return namedTypeRef({ name }); + }; +} + +function rewriteParam(p: MethodParam, newType: TypeRef): MethodParam { + return methodParam({ + paramKind: p.paramKind, + name: p.name, + typeRef: newType, + optional: p.optional, + default: p.default, + }); +} + +/** Walk a list of TypeDef nodes; for each StructureType field whose type is a + * TableType or a nested StructureType, hoist it into a sibling typedef and + * rewrite the field to reference it. The returned list is a superset of the + * input in topological order. */ +function normalizeTypeSection( + defs: readonly TypeDef[], + usedNames: Set, +): TypeDef[] { + const out: TypeDef[] = []; + const hoistOne = ( + t: TypeRef, + parentName: string, + fieldName: string, + ): TypeRef => { + if (t.kind === 'BuiltinType' || t.kind === 'NamedTypeRef') return t; + // Allocate a stable hoisted name. + let base = `${parentName}__${fieldName}`; + if (t.kind === 'TableType') base = `${base}_tab`; + let name = base; + let i = 2; + while (usedNames.has(name)) { + name = `${base}_${i}`; + i += 1; + } + usedNames.add(name); + // Recursively normalise nested structures. + let normalised: TypeRef; + if (t.kind === 'TableType') { + const rowRef = + t.rowType.kind === 'StructureType' + ? hoistOne(t.rowType, name, 'row') + : t.rowType; + normalised = tableType({ + rowType: rowRef, + tableKind: t.tableKind, + uniqueness: t.uniqueness, + keyFields: t.keyFields, + }); + } else { + // StructureType: rewrite its fields. + normalised = { + ...t, + fields: t.fields.map((f) => ({ + name: f.name, + type: hoistOne(f.type, name, f.name), + })), + }; + } + out.push(typeDef({ name, type: normalised })); + return namedTypeRef({ name }); + }; + for (const d of defs) { + const t = d.type; + if (t.kind === 'StructureType') { + const newFields = t.fields.map((f) => ({ + name: f.name, + type: hoistOne(f.type, d.name, f.name), + })); + out.push( + typeDef({ + name: d.name, + type: { ...t, fields: newFields }, + }), + ); + } else { + out.push(d); + } + } + return out; +} + +/** Convert leading-`*` star comments into `"` line comments so the printer's + * indentation-prefix machinery doesn't invalidate them (star comments must + * start at column 1 of the physical line in ABAP; line comments can be + * indented). + * + * Exported so callers that inject raw runtime ABAP into printed source can + * normalise comments before splicing. + */ +export function sanitizeStarComments(source: string): string { + return source + .split('\n') + .map((l) => { + const m = /^(\s*)\*(\s?)(.*)$/.exec(l); + if (!m) return l; + return `${m[1]}" ${m[3]}`; + }) + .join('\n'); +} + +/** Build the whole client class + local exception class. */ +export function emitClientClass( + spec: NormalizedSpec, + plan: TypePlan, + profile: TargetProfile, + runtime: CloudRuntime, + opts: EmitClientOptions, +): EmittedClient { + void opts.typePrefix; // prefix already baked into the plan. + const className = opts.className; + const exceptionClassName = exceptionClassNameFor(className); + + // Whitelist sanity: every system class the runtime references must be + // allowed by the profile. Throws WhitelistViolationError on mismatch. + for (const ref of runtime.allowedClassReferences) { + assertClassAllowed(profile, ref); + } + for (const ref of [ + 'if_web_http_client', + 'if_web_http_request', + 'if_web_http_response', + 'cl_http_utility', + ]) { + assertClassAllowed(profile, ref); + } + + // Type section: all planned types (normalized to avoid inline-unsafe types). + const rawTypes: TypeDef[] = emitTypeSection(plan, profile); + const typeNameSet = new Set(rawTypes.map((t) => t.name)); + const types: TypeDef[] = normalizeTypeSection(rawTypes, typeNameSet); + + // Server constants (class-constants, public for easy access). + const serverConstants: ConstantDecl[] = emitServerConstants(spec.servers); + + // Security support. + const sec = emitSecuritySupport(spec); + + // Protected attributes (server/destination + security). + const protectedAttributes: AttributeDef[] = [ + attributeDef({ + name: 'mv_server', + type: builtinType({ name: 'string' }), + visibility: 'protected', + }), + attributeDef({ + name: 'mv_destination', + type: builtinType({ name: 'string' }), + visibility: 'protected', + }), + ...sec.attributes.map((a) => reVisibility(a, 'protected')), + ]; + + // Constructor. + const ctorParams: MethodParam[] = [ + ...emitServerCtorParams(spec.servers), + ...sec.ctorParams, + ]; + const ctorBody: Statement[] = [ + raw({ source: `me->mv_server = iv_server.` }), + raw({ source: `me->mv_destination = iv_destination.` }), + ...sec.ctorStatements, + ]; + const constructorDef: MethodDef = methodDef({ + name: 'constructor', + visibility: 'public', + params: ctorParams, + }); + const constructorImpl: MethodImpl = methodImpl({ + name: 'constructor', + body: ctorBody, + }); + + // Per-operation methods. + const methodAllocator = makeNameAllocator(new Set(['constructor'])); + const extraTypes: TypeDef[] = []; + const extraTypeNames = new Set(types.map((t) => t.name)); + const hoist = makeTypeHoister(extraTypes, extraTypeNames); + const operationMethods: MethodDef[] = []; + const operationImpls: MethodImpl[] = []; + // Per-operation serialize/deserialize stubs. These are private helpers that + // live in the private section so the generated class activates cleanly. + // Byte-level JSON round-trip fidelity is deferred — the stubs return initial + // values (or abap_true for empty-body success) with a TODO comment. + const stubDecls: MethodDef[] = []; + const stubImpls: MethodImpl[] = []; + for (const op of spec.operations) { + const methodName = methodNameFor(op, methodAllocator); + const { params, body } = buildImportingParams(op, plan); + const ret = buildReturning(op, plan); + const importingParams: MethodParam[] = params.map((t) => { + const hoisted = hoist(t.param.typeRef, `${methodName}_${t.param.name}`); + return hoisted === t.param.typeRef + ? t.param + : rewriteParam(t.param, hoisted); + }); + let hoistedBodyParam: MethodParam | undefined; + if (body) { + const hoisted = hoist(body.param.typeRef, `${methodName}_body`); + hoistedBodyParam = + hoisted === body.param.typeRef + ? body.param + : rewriteParam(body.param, hoisted); + importingParams.push(hoistedBodyParam); + } + const allParams: MethodParam[] = [...importingParams]; + let hoistedReturningParam: MethodParam | undefined; + if (ret.returning) { + const hoisted = hoist( + ret.returning.typeRef, + `${methodName}_${ret.returning.name}`, + ); + hoistedReturningParam = + hoisted === ret.returning.typeRef + ? ret.returning + : rewriteParam(ret.returning, hoisted); + allParams.push(hoistedReturningParam); + } + operationMethods.push( + methodDef({ + name: methodName, + visibility: 'public', + params: allParams, + raising: buildRaising(exceptionClassName), + }), + ); + operationImpls.push( + methodImpl({ + name: methodName, + body: buildOperationBody({ + op, + methodName, + params, + body: body + ? { abapName: body.abapName, mediaType: body.mediaType } + : undefined, + ret, + spec, + exceptionClassName, + }), + }), + ); + + // --- _des_ stub. We emit one stub for every operation so + // the class is stable and extensible, even though the generated body + // only invokes it for JSON responses (binary responses call + // lo_resp->get_binary() directly, and bool responses assign abap_true). + if (hoistedReturningParam) { + const desName = `_des_${methodName}`; + const retName = hoistedReturningParam.name; + const isBool = ret.kind === 'bool'; + const desBody = isBool + ? `" TODO: implement JSON -> target deserialization if this endpoint ever +" returns a payload. For now this is an empty-body success: ${retName} = abap_true. +${retName} = abap_true. +RETURN.` + : `" TODO: implement JSON -> target deserialization using private _json_tokenize. +" For now ${retName} is returned with its initial value so the class activates cleanly. +CLEAR ${retName}. +RETURN.`; + stubDecls.push( + methodDef({ + name: desName, + visibility: 'private', + params: [ + methodParam({ + paramKind: 'importing', + name: 'iv_payload', + typeRef: builtinType({ name: 'string' }), + }), + methodParam({ + paramKind: 'returning', + name: retName, + typeRef: hoistedReturningParam.typeRef, + }), + ], + }), + ); + stubImpls.push( + methodImpl({ + name: desName, + body: [raw({ source: desBody })], + }), + ); + } + + // --- _ser_ stub (emitted whenever the operation has a + // request body, regardless of media type — it is only invoked from the + // generated body for JSON media types, but we emit the helper + // unconditionally so the public API is stable for future expansion). --- + if (body && hoistedBodyParam) { + const serName = `_ser_${methodName}`; + stubDecls.push( + methodDef({ + name: serName, + visibility: 'private', + params: [ + methodParam({ + paramKind: 'importing', + name: hoistedBodyParam.name, + typeRef: hoistedBodyParam.typeRef, + }), + methodParam({ + paramKind: 'returning', + name: 'rv_json', + typeRef: builtinType({ name: 'string' }), + }), + ], + }), + ); + stubImpls.push( + methodImpl({ + name: serName, + body: [ + raw({ + source: `" TODO: implement target -> JSON serialization using private _json_write_* helpers. +" For now rv_json is returned empty so the class activates cleanly. +CLEAR rv_json. +RETURN.`, + }), + ], + }), + ); + } + } + + // NOTE: the runtime's METHODS declarations and METHOD ... ENDMETHOD bodies + // are NOT injected into the AST (the AST has no representation for a raw + // METHOD block at class-IMPLEMENTATION level, and nesting them inside + // another METHOD is syntactically invalid ABAP). Instead the caller + // (`generate.ts`) performs a string-level injection of + // `runtime.declarations` into the PRIVATE SECTION and + // `runtime.implementations` into the IMPLEMENTATION block after printing. + // The assembler still imports the runtime so its whitelist of + // `allowedClassReferences` participates in profile enforcement below. + void runtime; + + const publicMembers: SectionMember[] = [ + ...types, + ...extraTypes, + ...serverConstants, + constructorDef, + ...sec.publicMethods, + ...operationMethods, + ]; + + const protectedMembers: SectionMember[] = [ + ...protectedAttributes, + ...sec.protectedMethods, + ]; + + const privateMembers: SectionMember[] = [...stubDecls]; + + const sections = [ + section({ visibility: 'public', members: publicMembers }), + section({ visibility: 'protected', members: protectedMembers }), + section({ visibility: 'private', members: privateMembers }), + ]; + + const clientClass = classDef({ + name: className, + sections, + implementations: [ + constructorImpl, + ...sec.publicImpls, + ...sec.protectedImpls, + ...operationImpls, + ...stubImpls, + ], + }); + + const exceptionClass = buildExceptionClass(className); + return { class: clientClass, extras: [exceptionClass] }; +} diff --git a/packages/openai-codegen/src/emit/exception-class.ts b/packages/openai-codegen/src/emit/exception-class.ts new file mode 100644 index 00000000..7518564d --- /dev/null +++ b/packages/openai-codegen/src/emit/exception-class.ts @@ -0,0 +1,73 @@ +import { + attributeDef, + builtinType, + call, + identifierExpr, + localClassDef, + methodDef, + methodImpl, + methodParam, + namedTypeRef, + raw, + section, + type LocalClassDef, +} from '@abapify/abap-ast'; +import { exceptionClassNameFor } from './identifiers'; + +/** Emit a local exception class that carries HTTP status + raw payload. */ +export function buildExceptionClass(clientClassName: string): LocalClassDef { + const name = exceptionClassNameFor(clientClassName); + void identifierExpr; + void call; + + const publicSection = section({ + visibility: 'public', + members: [ + attributeDef({ + name: 'mv_status', + type: builtinType({ name: 'i' }), + visibility: 'public', + readOnly: true, + }), + attributeDef({ + name: 'mv_payload', + type: builtinType({ name: 'string' }), + visibility: 'public', + readOnly: true, + }), + methodDef({ + name: 'constructor', + visibility: 'public', + params: [ + methodParam({ + paramKind: 'importing', + name: 'iv_status', + typeRef: builtinType({ name: 'i' }), + }), + methodParam({ + paramKind: 'importing', + name: 'iv_payload', + typeRef: builtinType({ name: 'string' }), + }), + ], + }), + ], + }); + + return localClassDef({ + name, + superclass: namedTypeRef({ name: 'cx_static_check' }).name, + isFinal: true, + sections: [publicSection], + implementations: [ + methodImpl({ + name: 'constructor', + body: [ + raw({ source: `super->constructor( ).` }), + raw({ source: `me->mv_status = iv_status.` }), + raw({ source: `me->mv_payload = iv_payload.` }), + ], + }), + ], + }); +} diff --git a/packages/openai-codegen/src/emit/identifiers.ts b/packages/openai-codegen/src/emit/identifiers.ts new file mode 100644 index 00000000..3748e49c --- /dev/null +++ b/packages/openai-codegen/src/emit/identifiers.ts @@ -0,0 +1,31 @@ +import { sanitizeIdent, type NameAllocator } from '../types/naming'; +import type { NormalizedOperation, NormalizedParameter } from '../oas/types'; + +/** Derive a stable ABAP method name for an operation via an allocator. */ +export function methodNameFor( + op: NormalizedOperation, + allocator: NameAllocator, +): string { + const raw = op.operationId || `${op.method}_${op.path}`; + return allocator(raw, 'method'); +} + +/** Derive a stable ABAP parameter name for an operation parameter. */ +export function paramNameFor( + p: NormalizedParameter, + allocator: NameAllocator, +): string { + return allocator(p.name, 'param', { prefix: 'iv_' }); +} + +/** Deterministic exception class name for a given client class. */ +export function exceptionClassNameFor(className: string): string { + const upper = className.toUpperCase(); + const tail = upper.startsWith('ZCL_') ? upper.slice(4) : upper; + return sanitizeIdent(`ZCX_${tail}_ERROR`, 'class', { maxLen: 30 }); +} + +/** Derive a stable ABAP attribute name from a logical key. */ +export function attributeNameFor(logical: string): string { + return sanitizeIdent(logical, 'param', { prefix: 'mv_' }); +} diff --git a/packages/openai-codegen/src/emit/index.ts b/packages/openai-codegen/src/emit/index.ts new file mode 100644 index 00000000..396e20e6 --- /dev/null +++ b/packages/openai-codegen/src/emit/index.ts @@ -0,0 +1,28 @@ +export { + methodNameFor, + paramNameFor, + exceptionClassNameFor, + attributeNameFor, +} from './identifiers'; +export { + buildImportingParams, + translateParameter, + translateRequestBody, + pickRequestMediaType, + makeMethodParamAllocator, +} from './parameters'; +export type { ParamTranslation } from './parameters'; +export { pickSuccessResponse, buildReturning, buildRaising } from './responses'; +export type { ReturnShape } from './responses'; +export { buildOperationBody } from './operation-body'; +export type { BuildBodyContext } from './operation-body'; +export { emitSecuritySupport, collectUsedSchemes } from './security'; +export type { SecuritySupport } from './security'; +export { buildExceptionClass } from './exception-class'; +export { + emitServerConstants, + emitServerCtorParams, + resolveServerUrl, +} from './server'; +export { emitClientClass, sanitizeStarComments } from './assemble'; +export type { EmitClientOptions, EmittedClient } from './assemble'; diff --git a/packages/openai-codegen/src/emit/operation-body.ts b/packages/openai-codegen/src/emit/operation-body.ts new file mode 100644 index 00000000..6eb6e16a --- /dev/null +++ b/packages/openai-codegen/src/emit/operation-body.ts @@ -0,0 +1,555 @@ +import { + append, + assign, + binOp, + builtinType, + call, + constructorExpr, + dataDecl, + identifierExpr, + ifStmt, + literal, + methodCallExpr, + namedTypeRef, + raise, + raw, + returnStmt, + stringTemplate, + type Expression, + type NamedArg, + type Statement, + type StringTemplatePart, +} from '@abapify/abap-ast'; +import type { + NormalizedOperation, + NormalizedSpec, + SecurityScheme, +} from '../oas/types'; +import { pickRequestMediaType, type ParamTranslation } from './parameters'; +import type { ReturnShape } from './responses'; + +export interface BuildBodyContext { + readonly op: NormalizedOperation; + readonly methodName: string; + readonly params: readonly ParamTranslation[]; + readonly body?: { abapName: string; mediaType: string }; + readonly ret: ReturnShape; + readonly spec: NormalizedSpec; + readonly exceptionClassName: string; +} + +/** Map an HTTP verb to the corresponding `if_web_http_client=>xxx` constant. */ +function httpMethodConstant(method: string): string { + const m = method.toLowerCase(); + switch (m) { + case 'get': + case 'post': + case 'put': + case 'delete': + case 'patch': + case 'head': + case 'options': + return `if_web_http_client=>${m}`; + default: + // Fallback: use the uppercase verb as a string literal via identifier. + return `if_web_http_client=>${m}`; + } +} + +/** Utility: lv_name = */ +function declString(name: string): Statement { + return dataDecl({ name, type: builtinType({ name: 'string' }) }); +} + +function declInt(name: string): Statement { + return dataDecl({ name, type: builtinType({ name: 'i' }) }); +} + +function declRef(name: string, to: string): Statement { + return dataDecl({ name, type: namedTypeRef({ name: `REF TO ${to}` }) }); +} + +function id(name: string): Expression { + return identifierExpr({ name }); +} + +function str(value: string): Expression { + return literal({ literalKind: 'string', value }); +} + +function arg(name: string, value: Expression): NamedArg { + return { name, value }; +} + +/** Build an `|{ text }|` string template substituting `{name}` placeholders with + * `me->_encode_path( iv_xxx )` calls. */ +function buildPathTemplate( + path: string, + pathParamAbapNames: ReadonlyMap, +): Expression { + const parts: StringTemplatePart[] = []; + const re = /\{([^}]+)\}/g; + let lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(path)) !== null) { + if (m.index > lastIndex) { + parts.push({ + partKind: 'text', + text: path.slice(lastIndex, m.index), + }); + } + const specName = m[1]; + const abapName = pathParamAbapNames.get(specName); + if (abapName) { + parts.push({ + partKind: 'expr', + expr: methodCallExpr({ + receiver: id('me'), + method: '_encode_path', + callKind: 'instance', + args: [arg('iv_value', id(abapName))], + }), + }); + } else { + // Unknown placeholder → leave literal. + parts.push({ partKind: 'text', text: `{${specName}}` }); + } + lastIndex = re.lastIndex; + } + if (lastIndex < path.length) { + parts.push({ partKind: 'text', text: path.slice(lastIndex) }); + } + if (parts.length === 0) { + parts.push({ partKind: 'text', text: path }); + } + return stringTemplate({ parts }); +} + +/** Resolve the security schemes that apply to an operation. */ +function resolveSecurity( + ctx: BuildBodyContext, +): Array<{ name: string; scheme: SecurityScheme }> { + const reqs = ctx.op.security; + const out: Array<{ name: string; scheme: SecurityScheme }> = []; + const seen = new Set(); + for (const req of reqs) { + for (const name of Object.keys(req)) { + if (seen.has(name)) continue; + const scheme = ctx.spec.securitySchemes[name]; + if (!scheme) continue; + out.push({ name, scheme }); + seen.add(name); + } + } + return out; +} + +function securityAttrNameFor(scheme: SecurityScheme, name: string): string { + const safe = name.replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase(); + switch (scheme.type) { + case 'apiKey': + return `mv_api_key_${safe}`; + case 'http': + if (scheme.scheme === 'bearer') return `mv_bearer_${safe}`; + if (scheme.scheme === 'basic') return `mv_basic_${safe}`; + return `mv_http_${safe}`; + default: + return `mv_sec_${safe}`; + } +} + +export function buildOperationBody(ctx: BuildBodyContext): Statement[] { + const stmts: Statement[] = []; + + // Local variable declarations. + stmts.push(declRef('lo_client', 'if_web_http_client')); + stmts.push(declRef('lo_req', 'if_web_http_request')); + stmts.push(declRef('lo_resp', 'if_web_http_response')); + stmts.push(declString('lv_url')); + stmts.push(declString('lv_path')); + stmts.push(declString('lv_body')); + stmts.push(declInt('lv_status')); + stmts.push(declString('lv_payload')); + stmts.push( + dataDecl({ + name: 'lt_query', + type: namedTypeRef({ name: 'string_table' }), + }), + ); + + // lo_client = me->_build_client( iv_destination = mv_destination ). + stmts.push( + assign({ + target: id('lo_client'), + value: methodCallExpr({ + receiver: id('me'), + method: '_build_client', + callKind: 'instance', + args: [arg('iv_destination', id('mv_destination'))], + }), + }), + ); + + // lo_req = lo_client->get_http_request( ). + stmts.push( + assign({ + target: id('lo_req'), + value: methodCallExpr({ + receiver: id('lo_client'), + method: 'get_http_request', + callKind: 'instance', + }), + }), + ); + + // Path parameters → lv_path = |...|. + const pathParams = ctx.params.filter((p) => p.source.in === 'path'); + const pathMap = new Map(); + for (const p of pathParams) pathMap.set(p.source.name, p.abapName); + stmts.push( + assign({ + target: id('lv_path'), + value: buildPathTemplate(ctx.op.path, pathMap), + }), + ); + + // Query parameters → APPEND ... TO lt_query. + const queryParams = ctx.params.filter((p) => p.source.in === 'query'); + for (const p of queryParams) { + const callExpr = methodCallExpr({ + receiver: id('me'), + method: '_serialize_query_param', + callKind: 'instance', + args: [ + arg('iv_name', str(p.source.name)), + arg( + 'iv_value', + // Use a string template to coerce arbitrary typed values into a + // printable string without relying on implicit conversions. + stringTemplate({ + parts: [{ partKind: 'expr', expr: id(p.abapName) }], + }), + ), + arg('iv_style', str(p.source.style ?? 'form')), + arg( + 'iv_explode', + p.source.explode ? id('abap_true') : id('abap_false'), + ), + ], + }); + stmts.push(append({ value: callExpr, table: id('lt_query') })); + } + + // Header parameters → lo_req->set_header_field. + const headerParams = ctx.params.filter((p) => p.source.in === 'header'); + for (const p of headerParams) { + stmts.push( + call({ + receiver: id('lo_req'), + method: 'set_header_field', + callKind: 'instance', + args: [ + arg('i_name', str(p.source.name)), + arg( + 'i_value', + stringTemplate({ + parts: [{ partKind: 'expr', expr: id(p.abapName) }], + }), + ), + ], + }), + ); + } + + // Request body. + if (ctx.body) { + const mt = ctx.body.mediaType; + const isJson = mt === 'application/json' || mt.endsWith('+json'); + if (isJson) { + stmts.push( + assign({ + target: id('lv_body'), + value: methodCallExpr({ + receiver: id('me'), + method: `_ser_${ctx.methodName}`, + callKind: 'instance', + args: [arg('is_body', id(ctx.body.abapName))], + }), + }), + ); + stmts.push( + call({ + receiver: id('lo_req'), + method: 'set_text', + callKind: 'instance', + args: [arg('i_text', id('lv_body'))], + }), + ); + } else { + // Binary body. + stmts.push( + call({ + receiver: id('lo_req'), + method: 'set_binary', + callKind: 'instance', + args: [arg('i_data', id(ctx.body.abapName))], + }), + ); + } + stmts.push( + call({ + receiver: id('lo_req'), + method: 'set_header_field', + callKind: 'instance', + args: [arg('i_name', str('content-type')), arg('i_value', str(mt))], + }), + ); + } + + // Security auto-injection. + for (const { name, scheme } of resolveSecurity(ctx)) { + const attr = securityAttrNameFor(scheme, name); + switch (scheme.type) { + case 'apiKey': { + if (scheme.in === 'header') { + stmts.push( + call({ + receiver: id('lo_req'), + method: 'set_header_field', + callKind: 'instance', + args: [arg('i_name', str(scheme.name)), arg('i_value', id(attr))], + }), + ); + } else if (scheme.in === 'query') { + stmts.push( + append({ + value: stringTemplate({ + parts: [ + { partKind: 'text', text: `${scheme.name}=` }, + { partKind: 'expr', expr: id(attr) }, + ], + }), + table: id('lt_query'), + }), + ); + } + break; + } + case 'http': { + if (scheme.scheme === 'bearer') { + stmts.push( + call({ + receiver: id('lo_req'), + method: 'set_header_field', + callKind: 'instance', + args: [ + arg('i_name', str('authorization')), + arg( + 'i_value', + stringTemplate({ + parts: [ + { partKind: 'text', text: 'Bearer ' }, + { partKind: 'expr', expr: id(attr) }, + ], + }), + ), + ], + }), + ); + } else if (scheme.scheme === 'basic') { + stmts.push( + call({ + receiver: id('lo_req'), + method: 'set_header_field', + callKind: 'instance', + args: [ + arg('i_name', str('authorization')), + arg( + 'i_value', + stringTemplate({ + parts: [ + { partKind: 'text', text: 'Basic ' }, + { + partKind: 'expr', + expr: methodCallExpr({ + receiver: id('cl_http_utility'), + method: 'encode_base64', + callKind: 'static', + args: [ + arg( + 'unencoded', + stringTemplate({ + parts: [ + { + partKind: 'expr', + expr: id(`${attr}_user`), + }, + { partKind: 'text', text: ':' }, + { + partKind: 'expr', + expr: id(`${attr}_password`), + }, + ], + }), + ), + ], + }), + }, + ], + }), + ), + ], + }), + ); + } + break; + } + case 'oauth2': + case 'openIdConnect': { + stmts.push( + call({ + receiver: id('me'), + method: 'on_authorize', + callKind: 'instance', + args: [arg('io_request', id('lo_req'))], + }), + ); + break; + } + } + } + + // lv_url = me->_join_url(...) + stmts.push( + assign({ + target: id('lv_url'), + value: methodCallExpr({ + receiver: id('me'), + method: '_join_url', + callKind: 'instance', + args: [ + arg('iv_server', id('mv_server')), + arg('iv_path', id('lv_path')), + arg('it_query', id('lt_query')), + ], + }), + }), + ); + + // lo_req->set_uri( lv_url ). + stmts.push( + call({ + receiver: id('lo_req'), + method: 'set_uri', + callKind: 'instance', + args: [arg('i_uri', id('lv_url'))], + }), + ); + + // lo_resp = me->_send_request(...) + stmts.push( + assign({ + target: id('lo_resp'), + value: methodCallExpr({ + receiver: id('me'), + method: '_send_request', + callKind: 'instance', + args: [ + arg('io_client', id('lo_client')), + arg('io_request', id('lo_req')), + arg('iv_method', id(httpMethodConstant(ctx.op.method))), + ], + }), + }), + ); + + // lv_status = lo_resp->get_status( )-code. + stmts.push(raw({ source: `lv_status = lo_resp->get_status( )-code.` })); + + // lv_payload = lo_resp->get_text( ). + stmts.push( + assign({ + target: id('lv_payload'), + value: methodCallExpr({ + receiver: id('lo_resp'), + method: 'get_text', + callKind: 'instance', + }), + }), + ); + + // Success branch: IF status >= 200 AND status < 300. + const successBody: Statement[] = []; + switch (ctx.ret.kind) { + case 'json': + successBody.push( + assign({ + target: id(ctx.ret.returning!.name), + value: methodCallExpr({ + receiver: id('me'), + method: `_des_${ctx.methodName}`, + callKind: 'instance', + args: [arg('iv_payload', id('lv_payload'))], + }), + }), + ); + break; + case 'binary': + successBody.push( + assign({ + target: id(ctx.ret.returning!.name), + value: methodCallExpr({ + receiver: id('lo_resp'), + method: 'get_binary', + callKind: 'instance', + }), + }), + ); + break; + case 'bool': + successBody.push( + assign({ + target: id(ctx.ret.returning!.name), + value: id('abap_true'), + }), + ); + break; + } + successBody.push(returnStmt()); + + stmts.push( + ifStmt({ + condition: binOp({ + op: 'AND', + left: binOp({ + op: '>=', + left: id('lv_status'), + right: literal({ literalKind: 'int', value: 200 }), + }), + right: binOp({ + op: '<', + left: id('lv_status'), + right: literal({ literalKind: 'int', value: 300 }), + }), + }), + then: successBody, + }), + ); + + // RAISE EXCEPTION NEW zcx_..._error( iv_status = lv_status iv_payload = lv_payload ). + stmts.push( + raise({ + exceptionType: namedTypeRef({ name: ctx.exceptionClassName }), + args: [ + arg('iv_status', id('lv_status')), + arg('iv_payload', id('lv_payload')), + ], + }), + ); + + // Silence unused imports. + void constructorExpr; + void pickRequestMediaType; + + return stmts; +} diff --git a/packages/openai-codegen/src/emit/parameters.ts b/packages/openai-codegen/src/emit/parameters.ts new file mode 100644 index 00000000..67dc784b --- /dev/null +++ b/packages/openai-codegen/src/emit/parameters.ts @@ -0,0 +1,105 @@ +import { + builtinType, + methodParam, + type MethodParam, + type TypeRef, + type BuiltinType, +} from '@abapify/abap-ast'; +import type { + NormalizedOperation, + NormalizedParameter, + NormalizedRequestBody, +} from '../oas/types'; +import type { TypePlan } from '../types/plan'; +import { mapSchemaToTypeRef } from '../types/map'; +import { makeNameAllocator, type NameAllocator } from '../types/naming'; +import { paramNameFor } from './identifiers'; + +export interface ParamTranslation { + readonly param: MethodParam; + readonly source: NormalizedParameter; + readonly abapName: string; +} + +/** Per-method allocator keeps param names unique within a signature. */ +export function makeMethodParamAllocator(): NameAllocator { + return makeNameAllocator(new Set()); +} + +/** Translate a NormalizedParameter to an ABAP METHODS importing param. */ +export function translateParameter( + p: NormalizedParameter, + plan: TypePlan, + allocator: NameAllocator, +): ParamTranslation { + const abapName = paramNameFor(p, allocator); + const typeRef: TypeRef = mapSchemaToTypeRef(p.schema, plan); + const param = methodParam({ + paramKind: 'importing', + name: abapName, + typeRef, + optional: !p.required, + }); + return { param, source: p, abapName }; +} + +/** Translate a request body to a single `is_body` importing parameter. */ +export function translateRequestBody( + rb: NormalizedRequestBody, + plan: TypePlan, + allocator: NameAllocator, +): { param: MethodParam; abapName: string; mediaType: string } | undefined { + const mediaType = pickRequestMediaType(rb); + if (!mediaType) return undefined; + const schema = rb.content[mediaType]!.schema; + const abapName = allocator('body', 'param', { prefix: 'is_' }); + const fmt = (schema as { format?: unknown }).format; + const typeRef: TypeRef = + fmt === 'binary' || fmt === 'byte' + ? (builtinType({ name: 'xstring' }) as BuiltinType) + : mapSchemaToTypeRef(schema, plan); + const param = methodParam({ + paramKind: 'importing', + name: abapName, + typeRef, + optional: rb.required === false, + }); + return { param, abapName, mediaType }; +} + +/** Prefer application/json (or +json); fall back to octet-stream / binary; else first available. */ +export function pickRequestMediaType( + rb: NormalizedRequestBody, +): string | undefined { + const keys = Object.keys(rb.content); + if (keys.length === 0) return undefined; + const json = keys.find( + (k) => k === 'application/json' || k.endsWith('+json'), + ); + if (json) return json; + const bin = keys.find( + (k) => k === 'application/octet-stream' || k.endsWith('binary'), + ); + if (bin) return bin; + return keys[0]; +} + +/** Build all importing params for an operation (path + query + header + body). */ +export function buildImportingParams( + op: NormalizedOperation, + plan: TypePlan, +): { + params: ParamTranslation[]; + body?: { param: MethodParam; abapName: string; mediaType: string }; +} { + const allocator = makeMethodParamAllocator(); + const params: ParamTranslation[] = []; + for (const p of op.parameters) { + if (p.in === 'cookie') continue; + params.push(translateParameter(p, plan, allocator)); + } + const body = op.requestBody + ? translateRequestBody(op.requestBody, plan, allocator) + : undefined; + return { params, body }; +} diff --git a/packages/openai-codegen/src/emit/responses.ts b/packages/openai-codegen/src/emit/responses.ts new file mode 100644 index 00000000..6fdbd658 --- /dev/null +++ b/packages/openai-codegen/src/emit/responses.ts @@ -0,0 +1,110 @@ +import { + builtinType, + methodParam, + namedTypeRef, + type MethodParam, + type TypeRef, +} from '@abapify/abap-ast'; +import type { NormalizedOperation, NormalizedResponse } from '../oas/types'; +import type { TypePlan } from '../types/plan'; +import { mapSchemaToTypeRef } from '../types/map'; + +/** The response we return to the caller on 2xx, plus the media type we read from it. */ +export interface ReturnShape { + /** undefined → no RETURNING param (shouldn't happen; we always emit abap_bool at minimum). */ + returning?: MethodParam; + /** 'json' | 'binary' | 'bool' */ + kind: 'json' | 'binary' | 'bool'; + selected?: NormalizedResponse; + mediaType?: string; +} + +/** Select the response to treat as the "success" response for RETURNING. */ +export function pickSuccessResponse( + op: NormalizedOperation, +): NormalizedResponse | undefined { + const byCode = (c: string) => op.responses.find((r) => r.statusCode === c); + const prefer = byCode('200') ?? byCode('201'); + if (prefer) return prefer; + const twoXx = op.responses + .filter((r) => /^2\d\d$/.test(r.statusCode)) + .sort((a, b) => Number(a.statusCode) - Number(b.statusCode)); + if (twoXx.length > 0) return twoXx[0]; + return byCode('default'); +} + +function pickResponseMediaType(resp: NormalizedResponse): string | undefined { + const keys = Object.keys(resp.content); + if (keys.length === 0) return undefined; + const json = keys.find( + (k) => k === 'application/json' || k.endsWith('+json'), + ); + if (json) return json; + const bin = keys.find( + (k) => k === 'application/octet-stream' || k.endsWith('binary'), + ); + if (bin) return bin; + return keys[0]; +} + +/** Build the RETURNING parameter for an operation based on its selected 2xx response. */ +export function buildReturning( + op: NormalizedOperation, + plan: TypePlan, +): ReturnShape { + const selected = pickSuccessResponse(op); + if (!selected) { + return { + returning: methodParam({ + paramKind: 'returning', + name: 'rv_success', + typeRef: builtinType({ name: 'abap_bool' }), + }), + kind: 'bool', + }; + } + const mediaType = pickResponseMediaType(selected); + if (!mediaType) { + return { + returning: methodParam({ + paramKind: 'returning', + name: 'rv_success', + typeRef: builtinType({ name: 'abap_bool' }), + }), + kind: 'bool', + selected, + }; + } + const schema = selected.content[mediaType]!.schema; + const isJson = + mediaType === 'application/json' || mediaType.endsWith('+json'); + if (isJson) { + const typeRef: TypeRef = mapSchemaToTypeRef(schema, plan); + return { + returning: methodParam({ + paramKind: 'returning', + name: 'rv_result', + typeRef, + }), + kind: 'json', + selected, + mediaType, + }; + } + // Binary/other: xstring. + return { + returning: methodParam({ + paramKind: 'returning', + name: 'rv_payload', + typeRef: builtinType({ name: 'xstring' }), + }), + kind: 'binary', + selected, + mediaType, + }; +} + +/** The RAISING clause always includes the generated exception class. */ +export function buildRaising(exceptionClassName: string): readonly TypeRef[] { + return [namedTypeRef({ name: exceptionClassName })]; +} diff --git a/packages/openai-codegen/src/emit/security.ts b/packages/openai-codegen/src/emit/security.ts new file mode 100644 index 00000000..8f893a20 --- /dev/null +++ b/packages/openai-codegen/src/emit/security.ts @@ -0,0 +1,243 @@ +import { + attributeDef, + builtinType, + identifierExpr, + literal, + methodDef, + methodImpl, + methodParam, + namedTypeRef, + raw, + type AttributeDef, + type MethodDef, + type MethodImpl, + type MethodParam, + type Statement, +} from '@abapify/abap-ast'; +import type { NormalizedSpec, SecurityScheme } from '../oas/types'; + +export interface SecuritySupport { + attributes: AttributeDef[]; + ctorParams: MethodParam[]; + ctorStatements: Statement[]; + publicMethods: MethodDef[]; + publicImpls: MethodImpl[]; + protectedMethods: MethodDef[]; + protectedImpls: MethodImpl[]; +} + +function safeId(name: string): string { + return name.replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase(); +} + +/** Collect the names of security schemes actually referenced by any operation + * or by the top-level security requirement. */ +export function collectUsedSchemes(spec: NormalizedSpec): string[] { + const names = new Set(); + for (const op of spec.operations) { + for (const req of op.security) { + for (const k of Object.keys(req)) names.add(k); + } + } + return [...names].filter((n) => spec.securitySchemes[n] !== undefined); +} + +function assignMe(target: string, source: string): Statement { + return raw({ source: `me->${target} = ${source}.` }); +} + +/** Build security-related declarations + ctor params + ctor statements. */ +export function emitSecuritySupport(spec: NormalizedSpec): SecuritySupport { + const names = collectUsedSchemes(spec); + const attributes: AttributeDef[] = []; + const ctorParams: MethodParam[] = []; + const ctorStatements: Statement[] = []; + const publicMethods: MethodDef[] = []; + const publicImpls: MethodImpl[] = []; + const protectedMethods: MethodDef[] = []; + const protectedImpls: MethodImpl[] = []; + let oauthAdded = false; + + for (const name of names) { + const scheme: SecurityScheme = spec.securitySchemes[name]; + const id = safeId(name); + switch (scheme.type) { + case 'apiKey': { + const attrName = `mv_api_key_${id}`; + attributes.push( + attributeDef({ + name: attrName, + type: builtinType({ name: 'string' }), + visibility: 'protected', + }), + ); + const paramName = `iv_api_key_${id}`; + ctorParams.push( + methodParam({ + paramKind: 'importing', + name: paramName, + typeRef: builtinType({ name: 'string' }), + optional: true, + }), + ); + ctorStatements.push(assignMe(attrName, paramName)); + break; + } + case 'http': { + if (scheme.scheme === 'bearer') { + const attrName = `mv_bearer_${id}`; + attributes.push( + attributeDef({ + name: attrName, + type: builtinType({ name: 'string' }), + visibility: 'protected', + }), + ); + const paramName = `iv_bearer_${id}`; + ctorParams.push( + methodParam({ + paramKind: 'importing', + name: paramName, + typeRef: builtinType({ name: 'string' }), + optional: true, + }), + ); + ctorStatements.push(assignMe(attrName, paramName)); + // Public setter: set_bearer_token (per-scheme suffixed). + const setter = `set_bearer_token_${id}`; + publicMethods.push( + methodDef({ + name: setter, + visibility: 'public', + params: [ + methodParam({ + paramKind: 'importing', + name: 'iv_token', + typeRef: builtinType({ name: 'string' }), + }), + ], + }), + ); + publicImpls.push( + methodImpl({ + name: setter, + body: [assignMe(attrName, 'iv_token')], + }), + ); + // Also emit the generic name once when there's only one bearer. + if (!publicMethods.some((m) => m.name === 'set_bearer_token')) { + publicMethods.push( + methodDef({ + name: 'set_bearer_token', + visibility: 'public', + params: [ + methodParam({ + paramKind: 'importing', + name: 'iv_token', + typeRef: builtinType({ name: 'string' }), + }), + ], + }), + ); + publicImpls.push( + methodImpl({ + name: 'set_bearer_token', + body: [assignMe(attrName, 'iv_token')], + }), + ); + } + } else if (scheme.scheme === 'basic') { + const userAttr = `mv_basic_${id}_user`; + const passAttr = `mv_basic_${id}_password`; + attributes.push( + attributeDef({ + name: userAttr, + type: builtinType({ name: 'string' }), + visibility: 'protected', + }), + ); + attributes.push( + attributeDef({ + name: passAttr, + type: builtinType({ name: 'string' }), + visibility: 'protected', + }), + ); + publicMethods.push( + methodDef({ + name: 'set_basic_auth', + visibility: 'public', + params: [ + methodParam({ + paramKind: 'importing', + name: 'iv_user', + typeRef: builtinType({ name: 'string' }), + }), + methodParam({ + paramKind: 'importing', + name: 'iv_password', + typeRef: builtinType({ name: 'string' }), + }), + ], + }), + ); + publicImpls.push( + methodImpl({ + name: 'set_basic_auth', + body: [ + assignMe(userAttr, 'iv_user'), + assignMe(passAttr, 'iv_password'), + ], + }), + ); + } + break; + } + case 'oauth2': + case 'openIdConnect': { + if (!oauthAdded) { + oauthAdded = true; + protectedMethods.push( + methodDef({ + name: 'on_authorize', + visibility: 'protected', + params: [ + methodParam({ + paramKind: 'importing', + name: 'io_request', + typeRef: namedTypeRef({ + name: 'REF TO if_web_http_request', + }), + }), + ], + }), + ); + protectedImpls.push( + methodImpl({ + name: 'on_authorize', + body: [ + raw({ source: `" override me` }), + raw({ source: `RETURN.` }), + ], + }), + ); + } + break; + } + } + } + + // Silence unused imports. + void identifierExpr; + void literal; + + return { + attributes, + ctorParams, + ctorStatements, + publicMethods, + publicImpls, + protectedMethods, + protectedImpls, + }; +} diff --git a/packages/openai-codegen/src/emit/server.ts b/packages/openai-codegen/src/emit/server.ts new file mode 100644 index 00000000..9ef32073 --- /dev/null +++ b/packages/openai-codegen/src/emit/server.ts @@ -0,0 +1,51 @@ +import { + builtinType, + constantDecl, + identifierExpr, + literal, + methodParam, + type ConstantDecl, + type MethodParam, +} from '@abapify/abap-ast'; +import type { NormalizedServer } from '../oas/types'; + +/** Substitute server variables with their declared defaults. */ +export function resolveServerUrl(server: NormalizedServer): string { + return server.url.replace(/\{([^}]+)\}/g, (_m, name) => { + const v = server.variables[name]; + return v && typeof v.default === 'string' ? v.default : ''; + }); +} + +export function emitServerConstants( + servers: readonly NormalizedServer[], +): ConstantDecl[] { + const list = servers.length > 0 ? servers : [{ url: '', variables: {} }]; + return list.map((s, idx) => + constantDecl({ + name: `co_server_${idx}`, + type: builtinType({ name: 'string' }), + value: literal({ literalKind: 'string', value: resolveServerUrl(s) }), + }), + ); +} + +/** Constructor server/destination params. */ +export function emitServerCtorParams( + _servers: readonly NormalizedServer[], +): MethodParam[] { + return [ + methodParam({ + paramKind: 'importing', + name: 'iv_server', + typeRef: builtinType({ name: 'string' }), + default: identifierExpr({ name: 'co_server_0' }), + }), + methodParam({ + paramKind: 'importing', + name: 'iv_destination', + typeRef: builtinType({ name: 'string' }), + optional: true, + }), + ]; +} diff --git a/packages/openai-codegen/src/format/abapgit.ts b/packages/openai-codegen/src/format/abapgit.ts new file mode 100644 index 00000000..80dab907 --- /dev/null +++ b/packages/openai-codegen/src/format/abapgit.ts @@ -0,0 +1,118 @@ +/** + * abapGit format writer. + * + * Produces the canonical layout consumed by the abapGit import flow: + * + * / + * package.devc.xml + * src/.clas.abap + * src/.clas.xml + * src/.clas.testclasses.abap (optional) + * src/.clas.locals_def.abap (optional) + * + * File names are lowercase (abapGit convention). The `.clas.xml` envelope is + * parameterised on CLSNAME / DESCRIPT / LANGU only — STATE/CLSCCINCL/FIXPT/ + * UNICODE are fixed to the defaults emitted by the SAP serializer for a + * freshly-generated global class (mirrors samples/petstore-abapgit/). + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { ClassArtifact, WriteResult } from './types'; +import { assertValidClassName } from './validate'; + +const PACKAGE_DEVC_XML = ` + + + + Generated by @abapify/openai-codegen + + + +`; + +function buildClasXml( + className: string, + description: string, + language: string, +): string { + return ` + + + + + ${className} + ${language} + ${escapeXml(description)} + 1 + X + X + X + + + + +`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export async function writeAbapgitLayout( + artifact: ClassArtifact, + outDir: string, +): Promise { + assertValidClassName(artifact.className); + + const language = artifact.language ?? 'E'; + const description = artifact.description ?? artifact.className; + const base = artifact.className.toLowerCase(); + + const srcDir = join(outDir, 'src'); + await mkdir(srcDir, { recursive: true }); + + const written: string[] = []; + + const pkgPath = join(outDir, 'package.devc.xml'); + await writeFile(pkgPath, PACKAGE_DEVC_XML, 'utf-8'); + written.push('package.devc.xml'); + + const mainAbap = join(srcDir, `${base}.clas.abap`); + await writeFile(mainAbap, artifact.mainSource, 'utf-8'); + written.push(`src/${base}.clas.abap`); + + const clasXml = join(srcDir, `${base}.clas.xml`); + await writeFile( + clasXml, + buildClasXml(artifact.className, description, language), + 'utf-8', + ); + written.push(`src/${base}.clas.xml`); + + if (artifact.testSource !== undefined) { + const p = join(srcDir, `${base}.clas.testclasses.abap`); + await writeFile(p, artifact.testSource, 'utf-8'); + written.push(`src/${base}.clas.testclasses.abap`); + } + + if (artifact.localsDefSource !== undefined) { + const p = join(srcDir, `${base}.clas.locals_def.abap`); + await writeFile(p, artifact.localsDefSource, 'utf-8'); + written.push(`src/${base}.clas.locals_def.abap`); + } + + if (artifact.localsImpSource !== undefined) { + const p = join(srcDir, `${base}.clas.locals_imp.abap`); + await writeFile(p, artifact.localsImpSource, 'utf-8'); + written.push(`src/${base}.clas.locals_imp.abap`); + } + + written.sort(); + return { files: written }; +} diff --git a/packages/openai-codegen/src/format/gcts.ts b/packages/openai-codegen/src/format/gcts.ts new file mode 100644 index 00000000..bef096c5 --- /dev/null +++ b/packages/openai-codegen/src/format/gcts.ts @@ -0,0 +1,116 @@ +/** + * gCTS / AFF format writer. + * + * gCTS (git-enabled CTS) and AFF (SAP/abap-file-formats) share the same + * on-disk layout: JSON metadata, per-object subdirectory named after the + * lowercased object name, `..` naming. + * + * For a class this produces: + * + * / + * package.devc.json + * CLAS//.clas.json + * CLAS//.clas.abap + * CLAS//.clas.testclasses.abap (optional) + * CLAS//.clas.locals_def.abap (optional) + * + * The `CLAS/` object-type subdirectory mirrors the layout used by gCTS + * repositories (objects grouped under their type code). See + * `packages/adt-plugin-gcts/` for filename conventions. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { ClassArtifact, WriteResult } from './types'; +import { assertValidClassName } from './validate'; + +const PACKAGE_DEVC_JSON = + JSON.stringify( + { + header: { + formatVersion: '1.0', + description: 'Generated by @abapify/openai-codegen', + }, + package: { + softwareComponent: 'LOCAL', + }, + }, + null, + 2, + ) + '\n'; + +function buildClasJson( + className: string, + description: string, + language: string, +): string { + return ( + JSON.stringify( + { + header: { + formatVersion: '1.0', + description, + originalLanguage: language, + }, + class: { + name: className, + category: '00', + visibility: 'public', + final: true, + abstract: false, + fixedPointArithmetic: true, + unicodeChecksActive: true, + }, + }, + null, + 2, + ) + '\n' + ); +} + +export async function writeGctsLayout( + artifact: ClassArtifact, + outDir: string, +): Promise { + assertValidClassName(artifact.className); + + const language = artifact.language ?? 'E'; + const description = artifact.description ?? artifact.className; + const base = artifact.className.toLowerCase(); + const objectDir = join(outDir, 'CLAS', base); + + await mkdir(objectDir, { recursive: true }); + + const written: string[] = []; + + const pkgPath = join(outDir, 'package.devc.json'); + await writeFile(pkgPath, PACKAGE_DEVC_JSON, 'utf-8'); + written.push('package.devc.json'); + + const metaPath = join(objectDir, `${base}.clas.json`); + await writeFile( + metaPath, + buildClasJson(artifact.className, description, language), + 'utf-8', + ); + written.push(`CLAS/${base}/${base}.clas.json`); + + const mainPath = join(objectDir, `${base}.clas.abap`); + await writeFile(mainPath, artifact.mainSource, 'utf-8'); + written.push(`CLAS/${base}/${base}.clas.abap`); + + if (artifact.testSource !== undefined) { + const p = join(objectDir, `${base}.clas.testclasses.abap`); + await writeFile(p, artifact.testSource, 'utf-8'); + written.push(`CLAS/${base}/${base}.clas.testclasses.abap`); + } + + if (artifact.localsDefSource !== undefined) { + const p = join(objectDir, `${base}.clas.locals_def.abap`); + await writeFile(p, artifact.localsDefSource, 'utf-8'); + written.push(`CLAS/${base}/${base}.clas.locals_def.abap`); + } + + written.sort(); + return { files: written }; +} diff --git a/packages/openai-codegen/src/format/index.ts b/packages/openai-codegen/src/format/index.ts new file mode 100644 index 00000000..a23520e9 --- /dev/null +++ b/packages/openai-codegen/src/format/index.ts @@ -0,0 +1,31 @@ +/** + * Format plugins barrel. + * + * Exposes per-format writers and a {@link writeLayout} dispatch helper that + * selects the writer from an {@link OutputFormat} string. + */ + +import { writeAbapgitLayout } from './abapgit'; +import { writeGctsLayout } from './gcts'; +import type { ClassArtifact, OutputFormat, WriteResult } from './types'; + +export { writeAbapgitLayout } from './abapgit'; +export { writeGctsLayout } from './gcts'; +export type { ClassArtifact, OutputFormat, WriteResult } from './types'; + +export function writeLayout( + artifact: ClassArtifact, + format: OutputFormat, + outDir: string, +): Promise { + switch (format) { + case 'abapgit': + return writeAbapgitLayout(artifact, outDir); + case 'gcts': + return writeGctsLayout(artifact, outDir); + default: { + const exhaustive: never = format; + throw new Error(`Unknown output format: ${String(exhaustive)}`); + } + } +} diff --git a/packages/openai-codegen/src/format/types.ts b/packages/openai-codegen/src/format/types.ts new file mode 100644 index 00000000..9bfd672c --- /dev/null +++ b/packages/openai-codegen/src/format/types.ts @@ -0,0 +1,31 @@ +/** + * Shared types for format plugins (abapGit / gCTS). + * + * A {@link ClassArtifact} is the abstract, format-agnostic description of a + * generated ABAP class. Format writers consume it and produce the on-disk + * layout expected by the respective import flow. + */ + +export type OutputFormat = 'abapgit' | 'gcts'; + +export interface ClassArtifact { + /** Uppercase class name, e.g. 'ZCL_PETSTORE3_CLIENT'. */ + className: string; + /** The printed ABAP source (main class include). */ + mainSource: string; + /** Optional test-class source. If present, must be written alongside. */ + testSource?: string; + /** Optional local include (types) source. */ + localsDefSource?: string; + /** Optional local include (implementation) source — for helper classes emitted alongside the main class. */ + localsImpSource?: string; + /** Optional description used in the .clas.xml metadata (DESCRIPT). */ + description?: string; + /** ADT language key, default 'E'. */ + language?: string; +} + +export interface WriteResult { + /** Relative paths written, sorted, forward-slash normalised. */ + files: readonly string[]; +} diff --git a/packages/openai-codegen/src/format/validate.ts b/packages/openai-codegen/src/format/validate.ts new file mode 100644 index 00000000..fd0a993c --- /dev/null +++ b/packages/openai-codegen/src/format/validate.ts @@ -0,0 +1,22 @@ +/** + * ABAP identifier validation shared by all format writers. + * + * Rules (SAP repository object naming): + * - Must start with 'Z' or 'Y' (customer namespace) + * - Allowed characters: A-Z, 0-9, '_' + * - Max 30 characters + * - Must be uppercase + */ +export function assertValidClassName(name: string): void { + if (typeof name !== 'string' || name.length === 0) { + throw new Error('className must be a non-empty string'); + } + if (name.length > 30) { + throw new Error(`Invalid ABAP class name '${name}': exceeds 30 characters`); + } + if (!/^[ZY][A-Z0-9_]*$/.test(name)) { + throw new Error( + `Invalid ABAP class name '${name}': must be uppercase, start with Z or Y, and contain only A-Z, 0-9, _`, + ); + } +} diff --git a/packages/openai-codegen/src/generate.ts b/packages/openai-codegen/src/generate.ts new file mode 100644 index 00000000..5032518f --- /dev/null +++ b/packages/openai-codegen/src/generate.ts @@ -0,0 +1,240 @@ +import { print } from '@abapify/abap-ast'; +import { loadSpec } from './oas/index'; +import { getProfile, type TargetProfileId } from './profiles/index'; +import { planTypes } from './types/index'; +import { getCloudRuntime, type CloudRuntime } from './runtime/index'; +import { emitClientClass, sanitizeStarComments } from './emit/index'; +import { + writeLayout, + type OutputFormat, + type ClassArtifact, + type WriteResult, +} from './format/index'; + +export interface GenerateOptions { + /** Path or URL to the OpenAPI spec, or a parsed object. */ + input: string | URL | object; + /** Output directory (the layout writer creates files inside it). */ + outDir: string; + /** Target SAP system profile. Only `s4-cloud` is implemented in v1. */ + target: TargetProfileId; + /** Packaging layout. */ + format: OutputFormat; + /** Uppercase ABAP class name, e.g. `ZCL_PETSTORE3_CLIENT`. */ + className: string; + /** + * Lower-case ABAP type prefix (without `ty_` or trailing underscore), + * e.g. `ps3` produces `ty_ps3_pet`. + */ + typePrefix: string; + /** Optional short description used in the generated `.clas.xml` DESCRIPT. */ + description?: string; +} + +export interface GenerateResult extends WriteResult { + className: string; + typeCount: number; + operationCount: number; + source: string; +} + +/** + * Run the full pipeline: + * OpenAPI → normalize → plan types → emit class AST → print → write layout. + * + * Deterministic: re-running with the same inputs produces byte-identical files. + */ +export async function generate( + options: GenerateOptions, +): Promise { + if (options.target !== 's4-cloud') { + throw new Error( + `target '${options.target}' is not implemented in v1; only 's4-cloud' is supported`, + ); + } + + const spec = await loadSpec(options.input); + const profile = getProfile(options.target); + const plan = planTypes(spec, { typePrefix: options.typePrefix }); + const runtime = getCloudRuntime(); + + const emitted = emitClientClass(spec, plan, profile, runtime, { + className: options.className, + typePrefix: options.typePrefix, + }); + + // Print the bare AST, then splice the raw HTTP/URL/JSON runtime into + // both the PRIVATE SECTION (declarations) and the class IMPLEMENTATION + // block (bodies). The AST has no representation for these raw ABAP + // blocks; we keep them as plain strings to avoid re-parsing ABAP. + const printedMain = print(emitted.class); + const mainWithRuntime = injectRuntime( + printedMain, + options.className, + runtime, + ); + // Steampunk rejects source with `" ` line comments in certain structural + // positions ("The class contains unknown comments which can't be stored"). + // Strip all line-only comments before writing. Inline trailing comments + // after code on the same line are kept. + const mainSource = stripLineComments(mainWithRuntime); + + // Emit the main class artifact first. + const mainArtifact: ClassArtifact = { + className: options.className, + mainSource, + description: options.description, + }; + const mainResult = await writeLayout( + mainArtifact, + options.format, + options.outDir, + ); + + const allFiles: string[] = [...mainResult.files]; + const sourceParts: string[] = [mainSource]; + + // Each extra is emitted as its own global class file so that the (ZCX_*) + // exception class has its own .clas.abap + .clas.xml, which is what + // Steampunk / abapGit expect. + for (const extra of emitted.extras) { + const extraSource = printExtraAsGlobalClass(extra.name, print(extra)); + sourceParts.push(extraSource); + const extraArtifact: ClassArtifact = { + className: extra.name, + mainSource: extraSource, + description: `Generated error type for ${options.className}`, + }; + const extraResult = await writeLayout( + extraArtifact, + options.format, + options.outDir, + ); + allFiles.push(...extraResult.files); + } + + // Deduplicate shared files (package.devc.xml) and keep sorted. + const uniqueFiles = Array.from(new Set(allFiles)).sort(); + const source = sourceParts.join('\n\n'); + + return { + files: uniqueFiles, + className: options.className, + typeCount: plan.entries.length, + operationCount: spec.operations.length, + source, + }; +} + +/** + * Rewrite a printed `LocalClassDef` into a global-class-compatible source. + * + * The printer emits a LocalClassDef with a header like + * `CLASS zcx_foo DEFINITION ... INHERITING FROM cx_static_check.` + * For a stand-alone abapGit / gCTS file we need the `PUBLIC CREATE PUBLIC` + * modifiers (Steampunk rejects activation otherwise). We patch the first + * line of the DEFINITION header only; the rest of the printed source is + * already valid. + */ +function printExtraAsGlobalClass(className: string, printed: string): string { + const defHeader = new RegExp( + String.raw`^(\s*CLASS\s+${className}\s+DEFINITION)(?!\s+PUBLIC)(\b)`, + 'm', + ); + return printed.replace(defHeader, '$1 PUBLIC CREATE PUBLIC$2'); +} + +/** + * Inject the raw HTTP/URL/JSON runtime into a printed class source: + * + * - {@link CloudRuntime.declarations} (METHODS lines) are spliced at the end + * of the PRIVATE SECTION, before the DEFINITION's closing `ENDCLASS.`. + * - {@link CloudRuntime.implementations} (METHOD…ENDMETHOD blocks) are + * spliced before the IMPLEMENTATION's closing `ENDCLASS.`. + * + * We split the printed source at `CLASS IMPLEMENTATION.` and perform + * the splice on each half separately so the pattern match can't escape its + * intended scope. Star comments inside the runtime are normalised to line + * comments so they survive the indented splice. + */ +function injectRuntime( + printed: string, + className: string, + runtime: CloudRuntime, +): string { + const implHeader = new RegExp( + String.raw`^CLASS\s+${className}\s+IMPLEMENTATION\.`, + 'm', + ); + const split = printed.split(implHeader); + if (split.length !== 2) { + throw new Error( + `generate: could not locate IMPLEMENTATION block for ${className} in printed source`, + ); + } + const [defPart, implPartWithoutHeader] = split; + const implHeaderMatch = implHeader.exec(printed); + const implHeaderLine = implHeaderMatch ? implHeaderMatch[0] : ''; + + // Splice declarations before the DEFINITION's closing `ENDCLASS.` + const indentedDecl = indentLines( + sanitizeStarComments(runtime.declarations), + 4, + ); + const defWithRuntime = defPart!.replace( + /\n(ENDCLASS\.\s*)$/, + `\n${indentedDecl}\n$1`, + ); + + // Splice implementations before the IMPLEMENTATION's closing `ENDCLASS.` + const indentedImpl = indentLines( + sanitizeStarComments(runtime.implementations), + 2, + ); + const implWithRuntime = implPartWithoutHeader!.replace( + /\n(ENDCLASS\.\s*)$/, + `\n${indentedImpl}\n$1`, + ); + + return `${defWithRuntime}${implHeaderLine}${implWithRuntime}`; +} + +function indentLines(source: string, spaces: number): string { + const pad = ' '.repeat(spaces); + return source + .split('\n') + .map((line) => (line.length > 0 ? pad + line : line)) + .join('\n'); +} + +/** + * Remove line-only comments (`"` at the start of a line, optionally + * indented) from an ABAP source string. Trailing comments after code on + * the same line are preserved. + * + * Steampunk's source-save endpoint rejects sources that contain comments + * it classifies as "unknown" — in practice any standalone line comment + * inside sections like PRIVATE SECTION between METHODS declarations. + * The safest option for a code-generator is to emit no standalone line + * comments at all; this post-processor enforces that. + */ +function stripLineComments(source: string): string { + const out: string[] = []; + for (const line of source.split('\n')) { + if (/^\s*"/.test(line)) continue; // drop pure line comment + out.push(line); + } + // Collapse multiple blank lines that can result from stripping. + const collapsed: string[] = []; + let blank = false; + for (const line of out) { + if (line.trim().length === 0) { + if (blank) continue; + blank = true; + } else { + blank = false; + } + collapsed.push(line); + } + return collapsed.join('\n'); +} diff --git a/packages/openai-codegen/src/index.ts b/packages/openai-codegen/src/index.ts new file mode 100644 index 00000000..a50b99c0 --- /dev/null +++ b/packages/openai-codegen/src/index.ts @@ -0,0 +1,13 @@ +export const OPENAI_CODEGEN_VERSION = '0.1.0'; + +export * from './oas/index'; +export * from './profiles/index'; +export * from './types/index'; +export * from './runtime/index'; +export * from './emit/index'; +export * from './format/index'; +export { + generate, + type GenerateOptions, + type GenerateResult, +} from './generate'; diff --git a/packages/openai-codegen/src/oas/index.ts b/packages/openai-codegen/src/oas/index.ts new file mode 100644 index 00000000..3fd1db0a --- /dev/null +++ b/packages/openai-codegen/src/oas/index.ts @@ -0,0 +1,4 @@ +export * from './types.js'; +export { loadSpec, normalizeSpec } from './load.js'; +export { walkSchemas, operationKey } from './iterate.js'; +export type { SchemaVisit, SchemaVisitor } from './iterate.js'; diff --git a/packages/openai-codegen/src/oas/iterate.ts b/packages/openai-codegen/src/oas/iterate.ts new file mode 100644 index 00000000..b20169ee --- /dev/null +++ b/packages/openai-codegen/src/oas/iterate.ts @@ -0,0 +1,91 @@ +import type { + JsonSchema, + NormalizedOperation, + NormalizedSpec, +} from './types.js'; + +export interface SchemaVisit { + path: string[]; + schema: JsonSchema; +} + +export type SchemaVisitor = (visit: SchemaVisit) => void; + +/** + * Depth-first walk over every distinct schema reachable from a normalized + * spec. Ordering is deterministic: components.schemas (by insertion order), + * then operations in spec order, with each operation yielding + * `parameters → requestBody → responses`. Inside each schema we descend into + * `properties`, `items`, `additionalProperties`, `allOf`/`anyOf`/`oneOf`, and + * `not`. + */ +export function walkSchemas(spec: NormalizedSpec, cb: SchemaVisitor): void { + const seen = new WeakSet(); + + const visit = (schema: unknown, path: string[]): void => { + if (!isRecord(schema)) return; + if (seen.has(schema)) return; + seen.add(schema); + cb({ path, schema }); + + if (isRecord(schema.properties)) { + for (const [name, sub] of Object.entries(schema.properties)) { + visit(sub, [...path, 'properties', name]); + } + } + if (schema.items !== undefined) { + visit(schema.items, [...path, 'items']); + } + if ( + schema.additionalProperties !== undefined && + schema.additionalProperties !== true && + schema.additionalProperties !== false + ) { + visit(schema.additionalProperties, [...path, 'additionalProperties']); + } + for (const combinator of ['allOf', 'anyOf', 'oneOf'] as const) { + const arr = schema[combinator]; + if (Array.isArray(arr)) { + arr.forEach((sub, idx) => { + visit(sub, [...path, combinator, String(idx)]); + }); + } + } + if (schema.not !== undefined) { + visit(schema.not, [...path, 'not']); + } + }; + + for (const [name, schema] of Object.entries(spec.schemas)) { + visit(schema, ['components', 'schemas', name]); + } + + for (const op of spec.operations) { + const base = ['operations', operationKey(op)]; + for (const p of op.parameters) { + visit(p.schema, [...base, 'parameters', `${p.in}:${p.name}`]); + } + if (op.requestBody !== undefined) { + for (const [mt, mtObj] of Object.entries(op.requestBody.content)) { + visit(mtObj.schema, [...base, 'requestBody', mt]); + } + } + for (const resp of op.responses) { + for (const [mt, mtObj] of Object.entries(resp.content)) { + visit(mtObj.schema, [...base, 'responses', resp.statusCode, mt]); + } + for (const [hn, h] of Object.entries(resp.headers)) { + visit(h.schema, [...base, 'responses', resp.statusCode, 'headers', hn]); + } + } + } +} + +/** Stable key used to identify an operation, e.g. `GET /pet/{petId}`. */ +export function operationKey(op: NormalizedOperation): string { + return `${op.method.toUpperCase()} ${op.path}`; +} + +function isRecord(x: unknown): x is Record { + return typeof x === 'object' && x !== null && !Array.isArray(x); +} diff --git a/packages/openai-codegen/src/oas/load.ts b/packages/openai-codegen/src/oas/load.ts new file mode 100644 index 00000000..cf82d4b3 --- /dev/null +++ b/packages/openai-codegen/src/oas/load.ts @@ -0,0 +1,508 @@ +import { readFile } from 'node:fs/promises'; +import SwaggerParser from '@apidevtools/swagger-parser'; +import YAML from 'yaml'; +import type { + HttpMethod, + JsonSchema, + NormalizedOperation, + NormalizedParameter, + NormalizedRequestBody, + NormalizedResponse, + NormalizedServer, + NormalizedSpec, + SecurityRequirement, + SecurityScheme, + SpecInfo, +} from './types.js'; + +type UnknownRecord = Record; + +const HTTP_METHODS: readonly HttpMethod[] = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head', + 'options', + 'trace', +] as const; + +function isRecord(x: unknown): x is UnknownRecord { + return typeof x === 'object' && x !== null && !Array.isArray(x); +} + +function asString(x: unknown): string | undefined { + return typeof x === 'string' ? x : undefined; +} + +function asBool(x: unknown, fallback: boolean): boolean { + return typeof x === 'boolean' ? x : fallback; +} + +function asArray(x: unknown): unknown[] { + return Array.isArray(x) ? x : []; +} + +function asStringArray(x: unknown): string[] { + return asArray(x).filter((v): v is string => typeof v === 'string'); +} + +/** + * Load and normalize an OpenAPI 3.x spec from a file path, URL, or in-memory + * object. YAML files (`.yaml` / `.yml`) are read manually with the `yaml` + * library first so we always hand SwaggerParser a parsed object — this keeps + * our behaviour identical for local files and in-memory inputs. + */ +export async function loadSpec( + input: string | URL | object, +): Promise { + let raw: unknown; + + if (typeof input === 'string' && /\.ya?ml$/i.test(input)) { + const text = await readFile(input, 'utf8'); + raw = YAML.parse(text); + } else if (input instanceof URL) { + raw = input.toString(); + } else { + raw = input; + } + + // Pre-capture component schema names before dereferencing (which would + // inline them and lose the named handles). + const namedSchemas = extractComponentSchemas(raw); + + let validated: unknown; + try { + // SwaggerParser mutates the input; pass a clone when it's an object so + // we don't pollute the caller's reference. + const parserInput = typeof raw === 'string' ? raw : structuredClone(raw); + validated = await ( + SwaggerParser.validate as (api: unknown) => Promise + )(parserInput); + } catch (err) { + throw formatValidationError(err); + } + + const dereferenced = (await ( + SwaggerParser.dereference as (api: unknown) => Promise + )(validated)) as UnknownRecord; + + return normalizeSpec(dereferenced, namedSchemas); +} + +function formatValidationError(err: unknown): Error { + if (err instanceof Error) { + const details = (err as Error & { details?: unknown }).details; + if (Array.isArray(details) && details.length > 0) { + const top = details + .slice(0, 3) + .map((d, i) => { + const msg = + isRecord(d) && typeof d.message === 'string' + ? d.message + : String(d); + return ` ${i + 1}. ${msg}`; + }) + .join('\n'); + return new Error(`OpenAPI spec validation failed:\n${top}`); + } + return new Error(`OpenAPI spec validation failed: ${err.message}`); + } + return new Error(`OpenAPI spec validation failed: ${String(err)}`); +} + +function extractComponentSchemas(raw: unknown): Record { + const out: Record = {}; + if (!isRecord(raw)) return out; + const components = raw.components; + if (!isRecord(components)) return out; + const schemas = components.schemas; + if (!isRecord(schemas)) return out; + for (const [name, schema] of Object.entries(schemas)) { + if (isRecord(schema)) { + out[name] = schema as JsonSchema; + } + } + return out; +} + +/** + * Normalize a (presumed-dereferenced) OpenAPI document into our internal + * shape. Callers who already have a dereferenced spec may invoke this + * directly. + */ +export function normalizeSpec( + dereferenced: unknown, + namedSchemas: Record = {}, +): NormalizedSpec { + if (!isRecord(dereferenced)) { + throw new Error('normalizeSpec: input must be an object'); + } + + const openapiVersion = asString(dereferenced.openapi) ?? ''; + const info = normalizeInfo(dereferenced.info); + const servers = normalizeServers(dereferenced.servers); + const securitySchemes = normalizeSecuritySchemes( + isRecord(dereferenced.components) + ? (dereferenced.components as UnknownRecord).securitySchemes + : undefined, + ); + const topLevelSecurity = normalizeSecurityRequirements(dereferenced.security); + const operations = normalizeOperations(dereferenced.paths, topLevelSecurity); + + // Prefer the pre-captured named schemas (from before dereferencing) so the + // original handles survive. Fall back to `components.schemas` from the + // dereferenced tree when the caller didn't supply them. + const schemas = + Object.keys(namedSchemas).length > 0 + ? namedSchemas + : extractComponentSchemas(dereferenced); + + return Object.freeze({ + openapiVersion, + info, + servers, + operations, + schemas, + securitySchemes, + }); +} + +function normalizeInfo(raw: unknown): SpecInfo { + if (!isRecord(raw)) { + return { title: '', version: '' }; + } + const info: SpecInfo = { + title: asString(raw.title) ?? '', + version: asString(raw.version) ?? '', + }; + const description = asString(raw.description); + if (description !== undefined) info.description = description; + const tos = asString(raw.termsOfService); + if (tos !== undefined) info.termsOfService = tos; + if (isRecord(raw.contact)) { + const contact: SpecInfo['contact'] = {}; + const name = asString(raw.contact.name); + if (name !== undefined) contact.name = name; + const url = asString(raw.contact.url); + if (url !== undefined) contact.url = url; + const email = asString(raw.contact.email); + if (email !== undefined) contact.email = email; + info.contact = contact; + } + if (isRecord(raw.license)) { + const license: NonNullable = { + name: asString(raw.license.name) ?? '', + }; + const url = asString(raw.license.url); + if (url !== undefined) license.url = url; + const id = asString(raw.license.identifier); + if (id !== undefined) license.identifier = id; + info.license = license; + } + return info; +} + +function normalizeServers(raw: unknown): NormalizedServer[] { + return asArray(raw) + .filter(isRecord) + .map((s) => { + const url = asString(s.url) ?? ''; + const variables: NormalizedServer['variables'] = {}; + if (isRecord(s.variables)) { + for (const [k, v] of Object.entries(s.variables)) { + if (!isRecord(v)) continue; + const variable: NormalizedServer['variables'][string] = { + default: asString(v.default) ?? '', + }; + const enums = asStringArray(v.enum); + if (enums.length > 0) variable.enum = enums; + const desc = asString(v.description); + if (desc !== undefined) variable.description = desc; + variables[k] = variable; + } + } + const server: NormalizedServer = { url, variables }; + const desc = asString(s.description); + if (desc !== undefined) server.description = desc; + return server; + }); +} + +function normalizeSecuritySchemes( + raw: unknown, +): Record { + const out: Record = {}; + if (!isRecord(raw)) return out; + for (const [name, schemeRaw] of Object.entries(raw)) { + if (!isRecord(schemeRaw)) continue; + const type = asString(schemeRaw.type); + const description = asString(schemeRaw.description); + switch (type) { + case 'apiKey': { + const inLoc = asString(schemeRaw.in); + if (inLoc !== 'header' && inLoc !== 'query' && inLoc !== 'cookie') + continue; + const scheme: SecurityScheme = { + type: 'apiKey', + name: asString(schemeRaw.name) ?? name, + in: inLoc, + }; + if (description !== undefined) scheme.description = description; + out[name] = scheme; + break; + } + case 'http': { + const scheme: SecurityScheme = { + type: 'http', + scheme: asString(schemeRaw.scheme) ?? 'bearer', + }; + const bf = asString(schemeRaw.bearerFormat); + if (bf !== undefined) scheme.bearerFormat = bf; + if (description !== undefined) scheme.description = description; + out[name] = scheme; + break; + } + case 'oauth2': { + const flowsRaw = isRecord(schemeRaw.flows) ? schemeRaw.flows : {}; + const scheme: SecurityScheme = { + type: 'oauth2', + flows: {}, + }; + for (const flowName of [ + 'implicit', + 'password', + 'clientCredentials', + 'authorizationCode', + ] as const) { + const f = flowsRaw[flowName]; + if (!isRecord(f)) continue; + const flow: { + authorizationUrl?: string; + tokenUrl?: string; + refreshUrl?: string; + scopes: Record; + } = { + scopes: {}, + }; + if (isRecord(f.scopes)) { + for (const [k, v] of Object.entries(f.scopes)) { + if (typeof v === 'string') flow.scopes[k] = v; + } + } + const au = asString(f.authorizationUrl); + if (au !== undefined) flow.authorizationUrl = au; + const tu = asString(f.tokenUrl); + if (tu !== undefined) flow.tokenUrl = tu; + const ru = asString(f.refreshUrl); + if (ru !== undefined) flow.refreshUrl = ru; + scheme.flows[flowName] = flow; + } + if (description !== undefined) scheme.description = description; + out[name] = scheme; + break; + } + case 'openIdConnect': { + const url = asString(schemeRaw.openIdConnectUrl); + if (url === undefined) continue; + const scheme: SecurityScheme = { + type: 'openIdConnect', + openIdConnectUrl: url, + }; + if (description !== undefined) scheme.description = description; + out[name] = scheme; + break; + } + default: + // Unsupported security scheme type — skip. + break; + } + } + return out; +} + +function normalizeSecurityRequirements(raw: unknown): SecurityRequirement[] { + return asArray(raw) + .filter(isRecord) + .map((req) => { + const out: SecurityRequirement = {}; + for (const [k, v] of Object.entries(req)) { + out[k] = asStringArray(v); + } + return out; + }); +} + +function normalizeOperations( + pathsRaw: unknown, + topLevelSecurity: SecurityRequirement[], +): NormalizedOperation[] { + const ops: NormalizedOperation[] = []; + if (!isRecord(pathsRaw)) return ops; + + // Iterate paths in insertion order for determinism. + for (const [path, pathItemRaw] of Object.entries(pathsRaw)) { + if (!isRecord(pathItemRaw)) continue; + const pathLevelParams = normalizeParameters(pathItemRaw.parameters); + + for (const method of HTTP_METHODS) { + const opRaw = pathItemRaw[method]; + if (!isRecord(opRaw)) continue; + + const opParams = normalizeParameters(opRaw.parameters); + const parameters = mergeParameters(pathLevelParams, opParams); + + const operationId = + asString(opRaw.operationId) ?? synthesizeOperationId(method, path); + + const op: NormalizedOperation = { + operationId, + method, + path, + tags: asStringArray(opRaw.tags), + deprecated: asBool(opRaw.deprecated, false), + parameters, + responses: normalizeResponses(opRaw.responses), + security: + opRaw.security === undefined + ? topLevelSecurity + : normalizeSecurityRequirements(opRaw.security), + }; + + const summary = asString(opRaw.summary); + if (summary !== undefined) op.summary = summary; + const description = asString(opRaw.description); + if (description !== undefined) op.description = description; + + const requestBody = normalizeRequestBody(opRaw.requestBody); + if (requestBody !== undefined) op.requestBody = requestBody; + + ops.push(op); + } + } + return ops; +} + +function normalizeParameters(raw: unknown): NormalizedParameter[] { + return asArray(raw) + .filter(isRecord) + .map((p) => { + const inLoc = asString(p.in); + if ( + inLoc !== 'path' && + inLoc !== 'query' && + inLoc !== 'header' && + inLoc !== 'cookie' + ) { + return undefined; + } + const name = asString(p.name); + if (name === undefined) return undefined; + + const param: NormalizedParameter = { + name, + in: inLoc, + required: inLoc === 'path' ? true : asBool(p.required, false), + schema: isRecord(p.schema) ? (p.schema as JsonSchema) : {}, + deprecated: asBool(p.deprecated, false), + }; + const style = asString(p.style); + if (style !== undefined) param.style = style; + if (typeof p.explode === 'boolean') param.explode = p.explode; + const desc = asString(p.description); + if (desc !== undefined) param.description = desc; + return param; + }) + .filter((p): p is NormalizedParameter => p !== undefined); +} + +function mergeParameters( + pathLevel: NormalizedParameter[], + opLevel: NormalizedParameter[], +): NormalizedParameter[] { + const key = (p: NormalizedParameter) => `${p.in}:${p.name}`; + const merged = new Map(); + for (const p of pathLevel) merged.set(key(p), p); + for (const p of opLevel) merged.set(key(p), p); // op-level wins + return [...merged.values()]; +} + +function normalizeRequestBody(raw: unknown): NormalizedRequestBody | undefined { + if (!isRecord(raw)) return undefined; + const content: NormalizedRequestBody['content'] = {}; + if (isRecord(raw.content)) { + for (const [mt, mtRaw] of Object.entries(raw.content)) { + if (!isRecord(mtRaw)) continue; + content[mt] = { + schema: isRecord(mtRaw.schema) ? (mtRaw.schema as JsonSchema) : {}, + }; + } + } + const body: NormalizedRequestBody = { + required: asBool(raw.required, false), + content, + }; + const desc = asString(raw.description); + if (desc !== undefined) body.description = desc; + return body; +} + +function normalizeResponses(raw: unknown): NormalizedResponse[] { + if (!isRecord(raw)) return []; + const entries = Object.entries(raw).filter( + (entry): entry is [string, UnknownRecord] => isRecord(entry[1]), + ); + const has2xx = entries.some(([code]) => /^2\d\d$/.test(code)); + return entries.map(([statusCode, respRaw]) => { + const isSuccess = + /^2\d\d$/.test(statusCode) || (statusCode === 'default' && !has2xx); + const isError = + /^(4|5)\d\d$/.test(statusCode) || (statusCode === 'default' && has2xx); + + const content: NormalizedResponse['content'] = {}; + if (isRecord(respRaw.content)) { + for (const [mt, mtRaw] of Object.entries(respRaw.content)) { + if (!isRecord(mtRaw)) continue; + content[mt] = { + schema: isRecord(mtRaw.schema) ? (mtRaw.schema as JsonSchema) : {}, + }; + } + } + + const headers: NormalizedResponse['headers'] = {}; + if (isRecord(respRaw.headers)) { + for (const [hn, hRaw] of Object.entries(respRaw.headers)) { + if (!isRecord(hRaw)) continue; + const header: NormalizedResponse['headers'][string] = { + schema: isRecord(hRaw.schema) ? (hRaw.schema as JsonSchema) : {}, + }; + const desc = asString(hRaw.description); + if (desc !== undefined) header.description = desc; + if (typeof hRaw.required === 'boolean') header.required = hRaw.required; + if (typeof hRaw.deprecated === 'boolean') + header.deprecated = hRaw.deprecated; + headers[hn] = header; + } + } + + const resp: NormalizedResponse = { + statusCode, + isSuccess, + isError, + content, + headers, + }; + const desc = asString(respRaw.description); + if (desc !== undefined) resp.description = desc; + return resp; + }); +} + +function synthesizeOperationId(method: HttpMethod, path: string): string { + const sanitized = path + .replace(/\{([^}]+)\}/g, '$1') + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return `${method}_${sanitized}`; +} diff --git a/packages/openai-codegen/src/oas/types.ts b/packages/openai-codegen/src/oas/types.ts new file mode 100644 index 00000000..6af3c206 --- /dev/null +++ b/packages/openai-codegen/src/oas/types.ts @@ -0,0 +1,162 @@ +/** + * Narrowed OpenAPI 3.1-compatible types used by the code generator. + * + * These are deliberately decoupled from `openapi-types` to keep the surface + * small, stable, and friendly to ABAP emitters. `JsonSchema` is kept as an + * open record for now — later waves will refine it into a discriminated union + * when we need richer structural reasoning. + */ + +export type JsonSchema = Record; + +export interface JsonSchemaRef { + $ref: string; +} + +export function isRef(x: unknown): x is JsonSchemaRef { + return ( + typeof x === 'object' && + x !== null && + typeof (x as { $ref?: unknown }).$ref === 'string' + ); +} + +export type HttpMethod = + | 'get' + | 'post' + | 'put' + | 'patch' + | 'delete' + | 'head' + | 'options' + | 'trace'; + +export type ParameterLocation = 'path' | 'query' | 'header' | 'cookie'; + +export interface SpecInfo { + title: string; + version: string; + description?: string; + termsOfService?: string; + contact?: { name?: string; url?: string; email?: string }; + license?: { name: string; url?: string; identifier?: string }; +} + +export interface NormalizedServerVariable { + default: string; + enum?: string[]; + description?: string; +} + +export interface NormalizedServer { + url: string; + description?: string; + variables: Record; +} + +export interface NormalizedParameter { + name: string; + in: ParameterLocation; + required: boolean; + schema: JsonSchema; + style?: string; + explode?: boolean; + description?: string; + deprecated: boolean; +} + +export interface NormalizedMediaType { + schema: JsonSchema; +} + +export interface NormalizedRequestBody { + required: boolean; + description?: string; + content: Record; +} + +export interface NormalizedHeader { + schema: JsonSchema; + description?: string; + required?: boolean; + deprecated?: boolean; +} + +export interface NormalizedResponse { + statusCode: string; + isSuccess: boolean; + isError: boolean; + description?: string; + content: Record; + headers: Record; +} + +export interface NormalizedOperation { + operationId: string; + method: HttpMethod; + path: string; + tags: string[]; + summary?: string; + description?: string; + deprecated: boolean; + parameters: NormalizedParameter[]; + requestBody?: NormalizedRequestBody; + responses: NormalizedResponse[]; + security: SecurityRequirement[]; +} + +export interface SecuritySchemeApiKey { + type: 'apiKey'; + name: string; + in: 'header' | 'query' | 'cookie'; + description?: string; +} + +export interface SecuritySchemeHttp { + type: 'http'; + scheme: 'bearer' | 'basic' | string; + bearerFormat?: string; + description?: string; +} + +export interface OAuthFlow { + authorizationUrl?: string; + tokenUrl?: string; + refreshUrl?: string; + scopes: Record; +} + +export interface SecuritySchemeOAuth2 { + type: 'oauth2'; + flows: { + implicit?: OAuthFlow; + password?: OAuthFlow; + clientCredentials?: OAuthFlow; + authorizationCode?: OAuthFlow; + }; + description?: string; +} + +export interface SecuritySchemeOpenIdConnect { + type: 'openIdConnect'; + openIdConnectUrl: string; + description?: string; +} + +export type SecurityScheme = + | SecuritySchemeApiKey + | SecuritySchemeHttp + | SecuritySchemeOAuth2 + | SecuritySchemeOpenIdConnect; + +/** Map of scheme-name → required scopes. */ +export type SecurityRequirement = Record; + +export interface NormalizedSpec { + openapiVersion: string; + info: SpecInfo; + servers: NormalizedServer[]; + operations: NormalizedOperation[]; + schemas: Record; + securitySchemes: Record; +} diff --git a/packages/openai-codegen/src/profiles/cloud.ts b/packages/openai-codegen/src/profiles/cloud.ts new file mode 100644 index 00000000..cc3483dd --- /dev/null +++ b/packages/openai-codegen/src/profiles/cloud.ts @@ -0,0 +1,29 @@ +import type { TargetProfile } from './types'; + +export const s4CloudProfile: TargetProfile = { + id: 's4-cloud', + description: + 'SAP S/4HANA Cloud (Steampunk) — uses if_web_http_client with destination provider, inline JSON runtime (no /ui2/cl_json).', + http: { + kind: 'if_web_http_client', + factoryClass: 'cl_web_http_client_manager', + destinationProviderClass: 'cl_http_destination_provider', + }, + json: { + kind: 'inline', + }, + allowedClasses: new Set([ + 'cl_web_http_client_manager', + 'cl_http_destination_provider', + 'if_web_http_client', + 'if_web_http_request', + 'if_web_http_response', + 'cl_http_utility', + 'cl_system_uuid', + 'cl_abap_char_utilities', + 'cl_abap_conv_codepage', + 'cl_abap_codepage', + 'cl_abap_conv_out_ce', + 'cl_abap_conv_in_ce', + ]), +}; diff --git a/packages/openai-codegen/src/profiles/errors.ts b/packages/openai-codegen/src/profiles/errors.ts new file mode 100644 index 00000000..93655db9 --- /dev/null +++ b/packages/openai-codegen/src/profiles/errors.ts @@ -0,0 +1,13 @@ +export class WhitelistViolationError extends Error { + readonly className: string; + readonly profileId: string; + + constructor(className: string, profileId: string) { + super( + `Class '${className}' is not in the allow-list for target profile '${profileId}'.`, + ); + this.name = 'WhitelistViolationError'; + this.className = className; + this.profileId = profileId; + } +} diff --git a/packages/openai-codegen/src/profiles/index.ts b/packages/openai-codegen/src/profiles/index.ts new file mode 100644 index 00000000..a45e79e3 --- /dev/null +++ b/packages/openai-codegen/src/profiles/index.ts @@ -0,0 +1,11 @@ +export type { + TargetProfileId, + TargetProfile, + HttpClientStrategy, + JsonStrategy, +} from './types'; +export { WhitelistViolationError } from './errors'; +export { s4CloudProfile } from './cloud'; +export { onPremClassicProfile } from './onprem-classic'; +export { s4OnPremModernProfile } from './onprem-modern'; +export { getProfile, assertClassAllowed, ALL_PROFILES } from './registry'; diff --git a/packages/openai-codegen/src/profiles/onprem-classic.ts b/packages/openai-codegen/src/profiles/onprem-classic.ts new file mode 100644 index 00000000..9e6314ce --- /dev/null +++ b/packages/openai-codegen/src/profiles/onprem-classic.ts @@ -0,0 +1,23 @@ +import type { TargetProfile } from './types'; + +// TODO: flesh out on-prem classic emitter details (Wave ≥2). +export const onPremClassicProfile: TargetProfile = { + id: 'on-prem-classic', + description: + 'TODO: Classic on-prem NetWeaver — uses if_http_client via cl_http_client and /ui2/cl_json for JSON.', + http: { + kind: 'if_http_client', + factoryClass: 'cl_http_client', + }, + json: { + kind: 'ui2_cl_json', + helperClass: '/ui2/cl_json', + }, + allowedClasses: new Set([ + 'cl_http_client', + 'if_http_client', + 'cl_http_utility', + 'cl_system_uuid', + '/ui2/cl_json', + ]), +}; diff --git a/packages/openai-codegen/src/profiles/onprem-modern.ts b/packages/openai-codegen/src/profiles/onprem-modern.ts new file mode 100644 index 00000000..e5e347e0 --- /dev/null +++ b/packages/openai-codegen/src/profiles/onprem-modern.ts @@ -0,0 +1,27 @@ +import type { TargetProfile } from './types'; + +// TODO: flesh out S/4 on-prem modern emitter details (Wave ≥2). +export const s4OnPremModernProfile: TargetProfile = { + id: 's4-onprem-modern', + description: + 'TODO: S/4HANA on-prem modern — uses if_web_http_client with destination provider, plus /ui2/cl_json for JSON.', + http: { + kind: 'if_web_http_client', + factoryClass: 'cl_web_http_client_manager', + destinationProviderClass: 'cl_http_destination_provider', + }, + json: { + kind: 'ui2_cl_json', + helperClass: '/ui2/cl_json', + }, + allowedClasses: new Set([ + 'cl_web_http_client_manager', + 'cl_http_destination_provider', + 'if_web_http_client', + 'if_web_http_request', + 'if_web_http_response', + 'cl_http_utility', + 'cl_system_uuid', + '/ui2/cl_json', + ]), +}; diff --git a/packages/openai-codegen/src/profiles/registry.ts b/packages/openai-codegen/src/profiles/registry.ts new file mode 100644 index 00000000..5c22eed2 --- /dev/null +++ b/packages/openai-codegen/src/profiles/registry.ts @@ -0,0 +1,43 @@ +import { s4CloudProfile } from './cloud'; +import { onPremClassicProfile } from './onprem-classic'; +import { s4OnPremModernProfile } from './onprem-modern'; +import { WhitelistViolationError } from './errors'; +import type { TargetProfile, TargetProfileId } from './types'; + +const PROFILES: Readonly> = { + 'on-prem-classic': onPremClassicProfile, + 's4-onprem-modern': s4OnPremModernProfile, + 's4-cloud': s4CloudProfile, +}; + +export const ALL_PROFILES: ReadonlyArray = [ + 'on-prem-classic', + 's4-onprem-modern', + 's4-cloud', +]; + +export function getProfile(id: TargetProfileId): TargetProfile { + const profile = PROFILES[id]; + if (!profile) { + throw new Error(`Unknown target profile: ${String(id)}`); + } + return profile; +} + +function normalizeAllowed(set: ReadonlySet): Set { + const out = new Set(); + for (const item of set) { + out.add(item.toLowerCase()); + } + return out; +} + +export function assertClassAllowed( + profile: TargetProfile, + className: string, +): void { + const normalized = normalizeAllowed(profile.allowedClasses); + if (!normalized.has(className.toLowerCase())) { + throw new WhitelistViolationError(className, profile.id); + } +} diff --git a/packages/openai-codegen/src/profiles/types.ts b/packages/openai-codegen/src/profiles/types.ts new file mode 100644 index 00000000..c68d0241 --- /dev/null +++ b/packages/openai-codegen/src/profiles/types.ts @@ -0,0 +1,25 @@ +export type TargetProfileId = + | 'on-prem-classic' + | 's4-onprem-modern' + | 's4-cloud'; + +export interface HttpClientStrategy { + kind: 'if_http_client' | 'if_web_http_client'; + factoryClass: string; + destinationProviderClass?: string; +} + +export interface JsonStrategy { + kind: 'ui2_cl_json' | 'inline'; + helperClass?: string; +} + +export interface TargetProfile { + id: TargetProfileId; + description: string; + http: HttpClientStrategy; + json: JsonStrategy; + /** Whitelist of system classes/interfaces the emitter may reference. + * Uppercase, no leading 'if_'/'cl_' stripping. Tested case-insensitively. */ + allowedClasses: ReadonlySet; +} diff --git a/packages/openai-codegen/src/runtime/index.ts b/packages/openai-codegen/src/runtime/index.ts new file mode 100644 index 00000000..f3c63b40 --- /dev/null +++ b/packages/openai-codegen/src/runtime/index.ts @@ -0,0 +1,22 @@ +export { getCloudRuntime } from './s4-cloud/index.js'; +export type { CloudRuntime } from './s4-cloud/index.js'; + +/** + * Placeholder for the on-prem classic (cl_http_client / /ui2/cl_json) runtime. + * Intentionally unimplemented in v1; only s4-cloud is supported. + */ +export function getClassicRuntime(): never { + throw new Error( + 'getClassicRuntime: not implemented in v1; only s4-cloud is supported', + ); +} + +/** + * Placeholder for the on-prem modern (if_web_http_client + /ui2/cl_json) runtime. + * Intentionally unimplemented in v1; only s4-cloud is supported. + */ +export function getModernRuntime(): never { + throw new Error( + 'getModernRuntime: not implemented in v1; only s4-cloud is supported', + ); +} diff --git a/packages/openai-codegen/src/runtime/s4-cloud/http.abap.ts b/packages/openai-codegen/src/runtime/s4-cloud/http.abap.ts new file mode 100644 index 00000000..4a139062 --- /dev/null +++ b/packages/openai-codegen/src/runtime/s4-cloud/http.abap.ts @@ -0,0 +1,44 @@ +/** + * s4-cloud HTTP runtime snippet. + * + * Emits the declaration lines (for PRIVATE SECTION) and matching method + * implementations for a thin wrapper over `if_web_http_client`. Only uses + * system classes allowed by the s4-cloud profile. + */ + +export const HTTP_RUNTIME_DECL_ABAP = `* --- HTTP runtime (s4-cloud) --- +METHODS _build_client + IMPORTING iv_destination TYPE string + RETURNING VALUE(ro_client) TYPE REF TO if_web_http_client + RAISING cx_web_http_client_error + cx_http_dest_provider_error. + +METHODS _send_request + IMPORTING io_client TYPE REF TO if_web_http_client + io_request TYPE REF TO if_web_http_request + iv_method TYPE string + RETURNING VALUE(ro_response) TYPE REF TO if_web_http_response + RAISING cx_web_http_client_error + cx_web_message_error. +`; + +export const HTTP_RUNTIME_IMPL_ABAP = `* --- HTTP runtime (s4-cloud) --- +METHOD _build_client. + " Resolve the communication arrangement identified by iv_destination + " and build a web-http client on top of it. Both classes are the only + " approved entry points on SAP BTP (Steampunk). + DATA(lo_destination) = cl_http_destination_provider=>create_by_comm_arrangement( + comm_scenario = iv_destination + comm_system_id = '' + service_id = '' ). + ro_client = cl_web_http_client_manager=>create_by_http_destination( lo_destination ). +ENDMETHOD. + +METHOD _send_request. + " The caller has already populated io_request via io_client->get_http_request( ). + " The HTTP method is passed via iv_method and must be one of the + " if_web_http_client=>get / =>post / =>put / =>delete / =>patch / + " =>head / =>options static constants. + ro_response = io_client->execute( i_method = iv_method ). +ENDMETHOD. +`; diff --git a/packages/openai-codegen/src/runtime/s4-cloud/index.ts b/packages/openai-codegen/src/runtime/s4-cloud/index.ts new file mode 100644 index 00000000..b94f7c50 --- /dev/null +++ b/packages/openai-codegen/src/runtime/s4-cloud/index.ts @@ -0,0 +1,59 @@ +import { HTTP_RUNTIME_DECL_ABAP, HTTP_RUNTIME_IMPL_ABAP } from './http.abap.js'; +import { URL_RUNTIME_DECL_ABAP, URL_RUNTIME_IMPL_ABAP } from './url.abap.js'; +import { JSON_RUNTIME_DECL_ABAP, JSON_RUNTIME_IMPL_ABAP } from './json.abap.js'; + +export interface CloudRuntime { + /** ABAP lines for the generated class' PRIVATE SECTION. */ + declarations: string; + /** ABAP METHOD ... ENDMETHOD blocks for the CLASS ... IMPLEMENTATION block. */ + implementations: string; + /** + * Whitelist proof: the set of system classes/interfaces the emitted + * runtime is permitted to reference. Tests cross-check that the actual + * ABAP only mentions identifiers from this set. + */ + allowedClassReferences: readonly string[]; +} + +const ALLOWED: readonly string[] = [ + 'cl_web_http_client_manager', + 'cl_http_destination_provider', + 'if_web_http_client', + 'if_web_http_request', + 'if_web_http_response', + 'cl_http_utility', + 'cl_system_uuid', + 'cl_abap_char_utilities', + 'cl_abap_conv_codepage', + 'cl_abap_codepage', + 'cl_abap_conv_out_ce', + 'cl_abap_conv_in_ce', +] as const; + +/** + * Get the reusable ABAP runtime snippets for the `s4-cloud` target profile. + * + * The operation emitter (Wave 3) drops `declarations` into the PRIVATE + * SECTION of the generated class and `implementations` into the + * IMPLEMENTATION block. Ordering (HTTP → URL → JSON) is stable so golden + * snapshots of generated classes don't churn. + */ +export function getCloudRuntime(): CloudRuntime { + const declarations = [ + HTTP_RUNTIME_DECL_ABAP, + URL_RUNTIME_DECL_ABAP, + JSON_RUNTIME_DECL_ABAP, + ].join('\n'); + + const implementations = [ + HTTP_RUNTIME_IMPL_ABAP, + URL_RUNTIME_IMPL_ABAP, + JSON_RUNTIME_IMPL_ABAP, + ].join('\n'); + + return { + declarations, + implementations, + allowedClassReferences: ALLOWED, + }; +} diff --git a/packages/openai-codegen/src/runtime/s4-cloud/json.abap.ts b/packages/openai-codegen/src/runtime/s4-cloud/json.abap.ts new file mode 100644 index 00000000..22976c48 --- /dev/null +++ b/packages/openai-codegen/src/runtime/s4-cloud/json.abap.ts @@ -0,0 +1,343 @@ +/** + * s4-cloud JSON runtime snippet. + * + * Hand-rolled RFC-8259 tokenizer + minimal writer helpers. This exists + * because /ui2/cl_json and xco_cp_json are not available on BTP Steampunk, + * and we want a zero-dependency, deterministic, inline JSON runtime. + * + * Correctness-critical features: + * - strings with \\", \\\\, \\/, \\b, \\f, \\n, \\r, \\t and \\uXXXX escapes, + * including UTF-16 surrogate pair handling for BMP-external code points, + * - numbers: optional leading '-', integer/fraction parts, scientific notation, + * - true / false / null literals, + * - whitespace skipping per RFC-8259 section 2. + */ + +export const JSON_RUNTIME_DECL_ABAP = `* --- JSON runtime (s4-cloud, inline, no external JSON class) --- +TYPES: BEGIN OF ty_json_token, + kind TYPE string, + str_val TYPE string, + num_val TYPE decfloat34, + bool_val TYPE abap_bool, + END OF ty_json_token, + ty_json_tokens TYPE STANDARD TABLE OF ty_json_token WITH DEFAULT KEY. + +METHODS _json_tokenize + IMPORTING iv_json TYPE string + RETURNING VALUE(rt_tokens) TYPE ty_json_tokens + RAISING cx_sy_conversion_no_number. + +METHODS _json_escape + IMPORTING iv_value TYPE string + RETURNING VALUE(rv) TYPE string. + +METHODS _json_write_string + IMPORTING iv_value TYPE string + CHANGING ct_parts TYPE string_table. + +METHODS _json_write_number + IMPORTING iv_value TYPE decfloat34 + CHANGING ct_parts TYPE string_table. + +METHODS _json_write_bool + IMPORTING iv_value TYPE abap_bool + CHANGING ct_parts TYPE string_table. + +METHODS _json_write_null + CHANGING ct_parts TYPE string_table. + +METHODS _json_concat + IMPORTING it_parts TYPE string_table + RETURNING VALUE(rv) TYPE string. + +METHODS _json_hex_to_int + IMPORTING iv_hex TYPE string + RETURNING VALUE(rv) TYPE i. + +METHODS _json_codepoint_to_string + IMPORTING iv_code TYPE i + RETURNING VALUE(rv) TYPE string. +`; + +export const JSON_RUNTIME_IMPL_ABAP = `* --- JSON runtime (s4-cloud, inline) --- +METHOD _json_escape. + " Escape the characters that MUST be escaped inside a JSON string. + " Order matters: backslash first, otherwise we'd double-escape the + " backslashes inserted for quote and whitespace escapes. + DATA(lv) = iv_value. + REPLACE ALL OCCURRENCES OF \`\\\\\` IN lv WITH \`\\\\\\\\\`. + REPLACE ALL OCCURRENCES OF \`"\` IN lv WITH \`\\\\"\`. + REPLACE ALL OCCURRENCES OF cl_abap_char_utilities=>newline IN lv WITH \`\\\\n\`. + REPLACE ALL OCCURRENCES OF cl_abap_char_utilities=>horizontal_tab IN lv WITH \`\\\\t\`. + rv = lv. +ENDMETHOD. + +METHOD _json_write_string. + APPEND |"{ _json_escape( iv_value ) }"| TO ct_parts. +ENDMETHOD. + +METHOD _json_write_number. + APPEND |{ iv_value }| TO ct_parts. +ENDMETHOD. + +METHOD _json_write_bool. + IF iv_value = abap_true. + APPEND \`true\` TO ct_parts. + ELSE. + APPEND \`false\` TO ct_parts. + ENDIF. +ENDMETHOD. + +METHOD _json_write_null. + APPEND \`null\` TO ct_parts. +ENDMETHOD. + +METHOD _json_concat. + rv = concat_lines_of( table = it_parts sep = \`\` ). +ENDMETHOD. + +METHOD _json_hex_to_int. + DATA: lv_i TYPE i VALUE 0, + lv_len TYPE i, + lv_c TYPE c LENGTH 1, + lv_v TYPE i. + rv = 0. + lv_len = strlen( iv_hex ). + WHILE lv_i < lv_len. + lv_c = iv_hex+lv_i(1). + IF lv_c CA \`0123456789\`. + lv_v = lv_c. + ELSEIF lv_c = \`a\` OR lv_c = \`A\`. + lv_v = 10. + ELSEIF lv_c = \`b\` OR lv_c = \`B\`. + lv_v = 11. + ELSEIF lv_c = \`c\` OR lv_c = \`C\`. + lv_v = 12. + ELSEIF lv_c = \`d\` OR lv_c = \`D\`. + lv_v = 13. + ELSEIF lv_c = \`e\` OR lv_c = \`E\`. + lv_v = 14. + ELSEIF lv_c = \`f\` OR lv_c = \`F\`. + lv_v = 15. + ENDIF. + rv = rv * 16 + lv_v. + lv_i = lv_i + 1. + ENDWHILE. +ENDMETHOD. + +METHOD _json_codepoint_to_string. + " Build the UTF-8 byte sequence for iv_code and convert it to a string + " via cl_abap_conv_codepage. This is the only approved codepage helper + " on BTP Steampunk. + DATA: lv_x TYPE xstring, + lv_byte TYPE x LENGTH 1, + lv_cp TYPE i. + lv_cp = iv_code. + IF lv_cp < 128. + lv_byte = lv_cp. + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSEIF lv_cp < 2048. + lv_byte = 192 + ( lv_cp DIV 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSEIF lv_cp < 65536. + lv_byte = 224 + ( lv_cp DIV 4096 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 64 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSE. + lv_byte = 240 + ( lv_cp DIV 262144 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 4096 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 64 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ENDIF. + rv = cl_abap_conv_codepage=>create_in( codepage = \`UTF-8\` )->convert( source = lv_x ). +ENDMETHOD. + +METHOD _json_tokenize. + " Single-pass tokenizer. We deliberately avoid regex here: ABAP regex + " semantics around backtracking in decoder corner cases are subtle, and + " a hand-written scanner keeps memory bounded to O(n). + DATA: lv_len TYPE i, + lv_pos TYPE i VALUE 0, + lv_ch TYPE c LENGTH 1, + ls_token TYPE ty_json_token, + lv_buf TYPE string, + lv_esc TYPE c LENGTH 1, + lv_hex TYPE string, + lv_code TYPE i, + lv_code2 TYPE i, + lv_char TYPE string, + lv_num_start TYPE i, + lv_num_len TYPE i, + lv_num_str TYPE string, + lv_num_val TYPE decfloat34. + + lv_len = strlen( iv_json ). + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + + " Whitespace: space, tab, LF, CR per RFC-8259 section 2. + IF lv_ch = \` \` + OR lv_ch = cl_abap_char_utilities=>horizontal_tab + OR lv_ch = cl_abap_char_utilities=>newline + OR lv_ch = cl_abap_char_utilities=>cr_lf(1). + lv_pos = lv_pos + 1. + CONTINUE. + ENDIF. + + CLEAR ls_token. + CASE lv_ch. + WHEN \`{\`. + ls_token-kind = \`object-start\`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN \`}\`. + ls_token-kind = \`object-end\`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN \`[\`. + ls_token-kind = \`array-start\`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN \`]\`. + ls_token-kind = \`array-end\`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN \`:\`. + ls_token-kind = \`colon\`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN \`,\`. + ls_token-kind = \`comma\`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + + WHEN \`"\`. + " String literal: scan until the matching (unescaped) double quote. + lv_pos = lv_pos + 1. + CLEAR lv_buf. + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + IF lv_ch = \`"\`. + lv_pos = lv_pos + 1. + EXIT. + ELSEIF lv_ch = \`\\\\\`. + " String escape loop: \\", \\\\, \\/, \\b, \\f, \\n, \\r, \\t, \\uXXXX. + lv_pos = lv_pos + 1. + lv_esc = iv_json+lv_pos(1). + CASE lv_esc. + WHEN \`"\`. + lv_buf = lv_buf && \`"\`. + WHEN \`\\\\\`. + lv_buf = lv_buf && \`\\\\\`. + WHEN \`/\`. + lv_buf = lv_buf && \`/\`. + WHEN \`b\`. + lv_buf = lv_buf && cl_abap_char_utilities=>backspace. + WHEN \`f\`. + lv_buf = lv_buf && cl_abap_char_utilities=>form_feed. + WHEN \`n\`. + lv_buf = lv_buf && cl_abap_char_utilities=>newline. + WHEN \`r\`. + lv_buf = lv_buf && cl_abap_char_utilities=>cr_lf(1). + WHEN \`t\`. + lv_buf = lv_buf && cl_abap_char_utilities=>horizontal_tab. + WHEN \`u\`. + " \\uXXXX: parse 4 hex digits, decode UTF-16 surrogate pairs + " so code points above U+FFFF survive the roundtrip. + lv_pos = lv_pos + 1. + lv_hex = iv_json+lv_pos(4). + lv_code = _json_hex_to_int( lv_hex ). + lv_pos = lv_pos + 3. + IF lv_code >= 55296 AND lv_code <= 56319. + " UTF-16 surrogate pair handling: high surrogate followed + " by a low surrogate yields the actual code point. + lv_pos = lv_pos + 1. + IF iv_json+lv_pos(2) = \`\\\\u\`. + lv_pos = lv_pos + 2. + lv_hex = iv_json+lv_pos(4). + lv_code2 = _json_hex_to_int( lv_hex ). + lv_pos = lv_pos + 3. + lv_code = ( lv_code - 55296 ) * 1024 + ( lv_code2 - 56320 ) + 65536. + ELSE. + lv_pos = lv_pos - 1. + ENDIF. + ENDIF. + lv_char = _json_codepoint_to_string( lv_code ). + lv_buf = lv_buf && lv_char. + WHEN OTHERS. + lv_buf = lv_buf && lv_esc. + ENDCASE. + lv_pos = lv_pos + 1. + ELSE. + lv_buf = lv_buf && lv_ch. + lv_pos = lv_pos + 1. + ENDIF. + ENDWHILE. + ls_token-kind = \`string\`. + ls_token-str_val = lv_buf. + APPEND ls_token TO rt_tokens. + + WHEN \`t\`. + IF lv_pos + 4 <= lv_len AND iv_json+lv_pos(4) = \`true\`. + ls_token-kind = \`bool\`. + ls_token-bool_val = abap_true. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 4. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN \`f\`. + IF lv_pos + 5 <= lv_len AND iv_json+lv_pos(5) = \`false\`. + ls_token-kind = \`bool\`. + ls_token-bool_val = abap_false. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 5. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN \`n\`. + IF lv_pos + 4 <= lv_len AND iv_json+lv_pos(4) = \`null\`. + ls_token-kind = \`null\`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 4. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN OTHERS. + " Number literal: [-]digits[.digits][eE[+-]digits]. We do exponent + " parsing in-place by consuming any character that could legally be + " part of a numeric token; the final conversion validates it. + lv_num_start = lv_pos. + IF lv_ch = \`-\`. + lv_pos = lv_pos + 1. + ENDIF. + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + IF lv_ch CA \`0123456789.eE+-\`. + lv_pos = lv_pos + 1. + ELSE. + EXIT. + ENDIF. + ENDWHILE. + lv_num_len = lv_pos - lv_num_start. + lv_num_str = iv_json+lv_num_start(lv_num_len). + lv_num_val = lv_num_str. + ls_token-kind = \`number\`. + ls_token-num_val = lv_num_val. + APPEND ls_token TO rt_tokens. + ENDCASE. + ENDWHILE. +ENDMETHOD. +`; diff --git a/packages/openai-codegen/src/runtime/s4-cloud/url.abap.ts b/packages/openai-codegen/src/runtime/s4-cloud/url.abap.ts new file mode 100644 index 00000000..1a455d33 --- /dev/null +++ b/packages/openai-codegen/src/runtime/s4-cloud/url.abap.ts @@ -0,0 +1,63 @@ +/** + * s4-cloud URL runtime snippet. + * + * Percent-encodes path segments, serialises query parameters (style=form) + * and joins a server + path + query list into a full URL. Uses + * cl_http_utility=>escape_url for encoding. + */ + +export const URL_RUNTIME_DECL_ABAP = `* --- URL runtime (s4-cloud) --- +METHODS _encode_path + IMPORTING iv_value TYPE string + RETURNING VALUE(rv_encoded) TYPE string. + +METHODS _serialize_query_param + IMPORTING iv_name TYPE string + iv_value TYPE string + iv_style TYPE string + iv_explode TYPE abap_bool + RETURNING VALUE(rv_qs) TYPE string. + +METHODS _join_url + IMPORTING iv_server TYPE string + iv_path TYPE string + it_query TYPE string_table + RETURNING VALUE(rv_url) TYPE string. +`; + +export const URL_RUNTIME_IMPL_ABAP = `* --- URL runtime (s4-cloud) --- +METHOD _encode_path. + " Percent-encode a single path segment. OpenAPI style=simple with explode + " toggled off is the only path style we emit, so a flat escape is enough. + rv_encoded = cl_http_utility=>escape_url( iv_value ). +ENDMETHOD. + +METHOD _serialize_query_param. + " style=form with explode=true/false both collapse to name=value for + " scalar types; array handling happens in the caller which invokes this + " helper once per element. iv_style / iv_explode are kept for signature + " stability across future emitter versions. + DATA(lv_name) = cl_http_utility=>escape_url( iv_name ). + DATA(lv_value) = cl_http_utility=>escape_url( iv_value ). + rv_qs = |{ lv_name }={ lv_value }|. +ENDMETHOD. + +METHOD _join_url. + " Concatenate server + path and append the query string (if any). + " Separators are inserted deterministically so callers do not need to + " know whether a query was already present. + rv_url = |{ iv_server }{ iv_path }|. + IF it_query IS NOT INITIAL. + DATA lv_first TYPE abap_bool VALUE abap_true. + rv_url = |{ rv_url }?|. + LOOP AT it_query INTO DATA(lv_q). + IF lv_first = abap_true. + rv_url = |{ rv_url }{ lv_q }|. + lv_first = abap_false. + ELSE. + rv_url = |{ rv_url }&{ lv_q }|. + ENDIF. + ENDLOOP. + ENDIF. +ENDMETHOD. +`; diff --git a/packages/openai-codegen/src/types/emit.ts b/packages/openai-codegen/src/types/emit.ts new file mode 100644 index 00000000..582ed70a --- /dev/null +++ b/packages/openai-codegen/src/types/emit.ts @@ -0,0 +1,20 @@ +import type { TypeDef } from '@abapify/abap-ast'; +import type { TargetProfile } from '../profiles/index'; +import type { TypePlan } from './plan'; +import { mapSchemaToTypeDef } from './map'; + +/** Emit all TypeDef nodes for the given plan, in topological order. */ +export function emitTypeSection( + plan: TypePlan, + _profile: TargetProfile, +): TypeDef[] { + // Profile is accepted for forward-compat (e.g. per-profile type substitutions) + // but currently does not alter emission. Silence unused-var lint. + void _profile; + const out: TypeDef[] = []; + for (const entry of plan.entries) { + const { typeDef } = mapSchemaToTypeDef(entry, plan); + out.push(typeDef); + } + return out; +} diff --git a/packages/openai-codegen/src/types/errors.ts b/packages/openai-codegen/src/types/errors.ts new file mode 100644 index 00000000..3e013aa4 --- /dev/null +++ b/packages/openai-codegen/src/types/errors.ts @@ -0,0 +1,20 @@ +export class CyclicTypeError extends Error { + constructor(message: string) { + super(message); + this.name = 'CyclicTypeError'; + } +} + +export class CollisionError extends Error { + constructor(message: string) { + super(message); + this.name = 'CollisionError'; + } +} + +export class UnsupportedSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnsupportedSchemaError'; + } +} diff --git a/packages/openai-codegen/src/types/index.ts b/packages/openai-codegen/src/types/index.ts new file mode 100644 index 00000000..2a003ad1 --- /dev/null +++ b/packages/openai-codegen/src/types/index.ts @@ -0,0 +1,11 @@ +export { sanitizeIdent, makeNameAllocator } from './naming'; +export type { IdentKind, SanitizeOpts, NameAllocator } from './naming'; +export { planTypes } from './plan'; +export type { TypePlan, TypePlanEntry, PlanTypesOptions } from './plan'; +export { mapPrimitive, mapSchemaToTypeRef, mapSchemaToTypeDef } from './map'; +export { emitTypeSection } from './emit'; +export { + CyclicTypeError, + CollisionError, + UnsupportedSchemaError, +} from './errors'; diff --git a/packages/openai-codegen/src/types/map.ts b/packages/openai-codegen/src/types/map.ts new file mode 100644 index 00000000..3bd2ffaa --- /dev/null +++ b/packages/openai-codegen/src/types/map.ts @@ -0,0 +1,423 @@ +import { + builtinType, + comment, + enumType, + namedTypeRef, + structureType, + tableType, + typeDef, + type BuiltinTypeName, + type Comment, + type EnumMember, + type StructureField, + type TypeDef, + type TypeRef, +} from '@abapify/abap-ast'; +import type { JsonSchema } from '../oas/index'; +import { isRef } from '../oas/index'; +import type { TypePlan, TypePlanEntry } from './plan'; +import { CollisionError, UnsupportedSchemaError } from './errors'; +import { sanitizeIdent } from './naming'; + +function isRecord(x: unknown): x is Record { + return typeof x === 'object' && x !== null && !Array.isArray(x); +} + +/** Map a JSON Schema primitive (type+format) to an ABAP builtin type name. */ +export function mapPrimitive(schema: JsonSchema): BuiltinTypeName { + const type = (schema as { type?: unknown }).type; + const format = (schema as { format?: unknown }).format; + if (type === 'boolean') return 'abap_bool'; + if (type === 'integer') { + return format === 'int64' ? 'int8' : 'i'; + } + if (type === 'number') { + return format === 'float' || format === 'double' ? 'f' : 'decfloat34'; + } + if (type === 'string') { + switch (format) { + case 'date': + return 'd'; + case 'date-time': + return 'timestampl'; + case 'uuid': + return 'sysuuid_x16'; + case 'byte': + case 'binary': + return 'xstring'; + default: + return 'string'; + } + } + // Unknown: fallback to string. + return 'string'; +} + +function refToEntry(ref: string, plan: TypePlan): TypePlanEntry | undefined { + const m = /^#\/components\/schemas\/(.+)$/.exec(ref); + if (!m) return undefined; + return plan.byId.get(`components.schemas.${m[1]}`); +} + +function pluckType(schema: JsonSchema): string | undefined { + const t = (schema as { type?: unknown }).type; + if (typeof t === 'string') return t; + if (Array.isArray(t)) { + const nonNull = t.filter((x): x is string => x !== 'null'); + if (nonNull.length >= 1) return nonNull[0]; + } + return undefined; +} + +function isNullable(schema: JsonSchema): boolean { + const rec = schema as Record; + if (rec.nullable === true) return true; + const t = rec.type; + if (Array.isArray(t) && t.includes('null')) return true; + return false; +} + +/** Resolve a schema to an ABAP TypeRef. */ +export function mapSchemaToTypeRef( + schema: JsonSchema, + plan: TypePlan, + ctx: { entryId?: string } = {}, +): TypeRef { + if (isRef(schema)) { + const entry = refToEntry(schema.$ref, plan); + if (!entry) { + throw new UnsupportedSchemaError( + `mapSchemaToTypeRef: unresolved $ref "${schema.$ref}"`, + ); + } + return namedTypeRef({ name: entry.abapName }); + } + const type = pluckType(schema); + // enum on string-like scalar → points to named entry when one exists; otherwise base string. + if (type === 'array') { + const items = (schema as { items?: unknown }).items; + const rowSchema: JsonSchema = isRecord(items) ? (items as JsonSchema) : {}; + return tableType({ + rowType: mapSchemaToTypeRef(rowSchema, plan, ctx), + tableKind: 'standard', + }); + } + if ( + type === 'object' || + isRecord((schema as { properties?: unknown }).properties) + ) { + // Prefer a planned entry for this exact schema if one exists. + const matched = findPlannedEntryBySchema(schema, plan, ctx.entryId); + if (matched) return namedTypeRef({ name: matched.abapName }); + // Inline structure as fallback. + const fields = buildStructureFields(schema, plan, ctx); + if (fields.length === 0) { + return builtinType({ name: 'string' }); + } + return structureType({ fields }); + } + if (type === undefined) { + // Untyped schema — unless combinator present. + for (const c of ['allOf', 'oneOf', 'anyOf'] as const) { + if (Array.isArray((schema as Record)[c])) { + // Treat as object; let typedef emission handle full shape. + const matched = findPlannedEntryBySchema(schema, plan, ctx.entryId); + if (matched) return namedTypeRef({ name: matched.abapName }); + return builtinType({ name: 'string' }); + } + } + return builtinType({ name: 'string' }); + } + return builtinType({ name: mapPrimitive(schema) }); +} + +/** If a schema is identical-by-reference to a planned entry, return it. */ +function findPlannedEntryBySchema( + schema: JsonSchema, + plan: TypePlan, + excludeId: string | undefined, +): TypePlanEntry | undefined { + for (const entry of plan.entries) { + if (entry.id === excludeId) continue; + if (entry.schema === schema) return entry; + } + return undefined; +} + +function buildEnumMembers(values: readonly unknown[]): EnumMember[] { + const out: EnumMember[] = []; + for (const v of values) { + if (typeof v === 'string') { + const name = sanitizeIdent(v, 'type'); + out.push({ name, value: v }); + } else if (typeof v === 'number') { + out.push({ name: sanitizeIdent(`v_${v}`, 'type'), value: v }); + } + } + return out; +} + +/** Build the list of structure fields for an object-like schema, including + * combinators, nullable flags, and additionalProperties escape hatch. */ +function buildStructureFields( + schema: JsonSchema, + plan: TypePlan, + ctx: { entryId?: string }, +): StructureField[] { + const fields: StructureField[] = []; + const seen = new Map(); + + const addField = (name: string, type: TypeRef, src: JsonSchema) => { + const sanitized = sanitizeIdent(name, 'param'); + const prev = seen.get(sanitized); + if (prev) { + // Only allow dedupe if structurally identical (by reference or same kind+name). + if (prev === type || shallowTypeRefEqual(prev, type)) return; + throw new CollisionError( + `buildStructureFields: duplicate field "${sanitized}" with conflicting types`, + ); + } + seen.set(sanitized, type); + fields.push({ name: sanitized, type }); + if (isNullable(src)) { + const flagName = sanitizeIdent(`${name}_is_null`, 'param'); + if (!seen.has(flagName)) { + const flagType = builtinType({ name: 'abap_bool' }); + seen.set(flagName, flagType); + fields.push({ name: flagName, type: flagType }); + } + } + }; + + // 1) allOf merging. + const allOf = (schema as { allOf?: unknown }).allOf; + if (Array.isArray(allOf)) { + for (const sub of allOf) { + if (!isRecord(sub)) continue; + const subSchema = sub as JsonSchema; + const subProps = isRef(subSchema) + ? ((): Record | undefined => { + const entry = refToEntry(subSchema.$ref, plan); + if (!entry) return undefined; + return (entry.schema as { properties?: Record }) + .properties; + })() + : (subSchema as { properties?: Record }).properties; + if (isRecord(subProps)) { + for (const [name, propSchema] of Object.entries(subProps)) { + if (!isRecord(propSchema)) continue; + addField( + name, + mapSchemaToTypeRef(propSchema as JsonSchema, plan, ctx), + propSchema as JsonSchema, + ); + } + } + } + } + + // 2) Own properties. + const props = (schema as { properties?: unknown }).properties; + if (isRecord(props)) { + for (const [name, propSchema] of Object.entries(props)) { + if (!isRecord(propSchema)) continue; + addField( + name, + mapSchemaToTypeRef(propSchema as JsonSchema, plan, ctx), + propSchema as JsonSchema, + ); + } + } + + // 3) oneOf / anyOf. + for (const combinator of ['oneOf', 'anyOf'] as const) { + const arr = (schema as Record)[combinator]; + if (!Array.isArray(arr)) continue; + const discriminator = (schema as { discriminator?: unknown }).discriminator; + if ( + isRecord(discriminator) && + typeof discriminator.propertyName === 'string' + ) { + // Tagged shape: kind TYPE string + one field per variant. + addFieldRaw(fields, seen, 'kind', builtinType({ name: 'string' })); + arr.forEach((sub, idx) => { + if (!isRecord(sub)) return; + const variantName = variantFieldName(sub as JsonSchema, idx); + addFieldRaw( + fields, + seen, + variantName, + mapSchemaToTypeRef(sub as JsonSchema, plan, ctx), + ); + }); + } else { + // Fallback: variant_kind string + one field per candidate + _is_set flag. + addFieldRaw( + fields, + seen, + 'variant_kind', + builtinType({ name: 'string' }), + ); + arr.forEach((sub, idx) => { + if (!isRecord(sub)) return; + const variantName = variantFieldName(sub as JsonSchema, idx); + addFieldRaw( + fields, + seen, + variantName, + mapSchemaToTypeRef(sub as JsonSchema, plan, ctx), + ); + addFieldRaw( + fields, + seen, + `${variantName}_is_set`, + builtinType({ name: 'abap_bool' }), + ); + }); + } + } + + // 4) additionalProperties escape hatch. + const ap = (schema as { additionalProperties?: unknown }) + .additionalProperties; + if (ap === true || isRecord(ap)) { + const kvEntry = plan.byId.get('aux.kv'); + if (kvEntry) { + addFieldRaw( + fields, + seen, + '_extra', + tableType({ + rowType: namedTypeRef({ name: kvEntry.abapName }), + tableKind: 'hashed', + uniqueness: 'unique', + keyFields: ['key'], + }), + ); + } + } + + return fields; +} + +function addFieldRaw( + fields: StructureField[], + seen: Map, + rawName: string, + type: TypeRef, +): void { + const name = sanitizeIdent(rawName, 'param'); + if (seen.has(name)) return; + seen.set(name, type); + fields.push({ name, type }); +} + +function shallowTypeRefEqual(a: TypeRef, b: TypeRef): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === 'BuiltinType' && b.kind === 'BuiltinType') { + return a.name === b.name; + } + if (a.kind === 'NamedTypeRef' && b.kind === 'NamedTypeRef') { + return a.name === b.name; + } + return false; +} + +function variantFieldName(sub: JsonSchema, idx: number): string { + if (isRef(sub)) { + const m = /^#\/components\/schemas\/(.+)$/.exec(sub.$ref); + if (m) return m[1]; + } + const title = (sub as { title?: unknown }).title; + if (typeof title === 'string' && title) return title; + return `variant_${idx}`; +} + +/** Build a TypeDef node for a single plan entry. */ +export function mapSchemaToTypeDef( + entry: TypePlanEntry, + plan: TypePlan, +): { typeDef: TypeDef; leadingComment?: Comment } { + const schema = entry.schema; + const ctx = { entryId: entry.id }; + + // Enum shortcut: string with enum values. + const enumValues = (schema as { enum?: unknown }).enum; + const schemaType = pluckType(schema); + if ( + Array.isArray(enumValues) && + (schemaType === 'string' || schemaType === undefined) + ) { + const members = buildEnumMembers(enumValues); + if (members.length > 0) { + return { + typeDef: typeDef({ + name: entry.abapName, + type: enumType({ + baseType: builtinType({ name: 'string' }), + members, + }), + }), + }; + } + } + + // Array alias. + if (schemaType === 'array') { + const items = (schema as { items?: unknown }).items; + const rowSchema: JsonSchema = isRecord(items) ? (items as JsonSchema) : {}; + return { + typeDef: typeDef({ + name: entry.abapName, + type: tableType({ + rowType: mapSchemaToTypeRef(rowSchema, plan, ctx), + tableKind: 'standard', + }), + }), + }; + } + + // Object / combinator. + const hasObjectShape = + schemaType === 'object' || + isRecord((schema as { properties?: unknown }).properties) || + Array.isArray((schema as { allOf?: unknown }).allOf) || + Array.isArray((schema as { oneOf?: unknown }).oneOf) || + Array.isArray((schema as { anyOf?: unknown }).anyOf); + + if (hasObjectShape) { + const fields = buildStructureFields(schema, plan, ctx); + const struct = + fields.length > 0 + ? structureType({ fields }) + : structureType({ + fields: [ + { + name: '_placeholder', + type: builtinType({ name: 'string' }), + }, + ], + }); + const discriminator = (schema as { discriminator?: unknown }).discriminator; + const leadingComment = + Array.isArray((schema as { oneOf?: unknown }).oneOf) && + isRecord(discriminator) + ? comment({ + text: ` Tagged union: only one variant field is populated at a time (kind discriminator).`, + style: 'star', + }) + : undefined; + return { + typeDef: typeDef({ name: entry.abapName, type: struct }), + leadingComment, + }; + } + + // Primitive alias. + return { + typeDef: typeDef({ + name: entry.abapName, + type: builtinType({ name: mapPrimitive(schema) }), + }), + }; +} diff --git a/packages/openai-codegen/src/types/naming.ts b/packages/openai-codegen/src/types/naming.ts new file mode 100644 index 00000000..c8ef0cdf --- /dev/null +++ b/packages/openai-codegen/src/types/naming.ts @@ -0,0 +1,96 @@ +import { createHash } from 'node:crypto'; + +export type IdentKind = 'type' | 'method' | 'param' | 'class'; + +export interface SanitizeOpts { + prefix?: string; + maxLen?: number; +} + +const DEFAULT_MAX_LEN = 30; + +/** Split an identifier on camelCase boundaries, non-alnum runs, and digits-prev-letter. */ +function splitWords(raw: string): string[] { + if (!raw) return []; + const cleaned = raw.replace(/[^A-Za-z0-9]+/g, ' ').trim(); + if (!cleaned) return []; + // Split on spaces, then further split on camelCase within each token. + const tokens = cleaned.split(/\s+/); + const out: string[] = []; + for (const tok of tokens) { + // Insert spaces before uppercase runs: "HTTPServer" -> "HTTP Server", "camelCase" -> "camel Case" + const spaced = tok + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + for (const part of spaced.split(/\s+/)) { + if (part) out.push(part); + } + } + return out; +} + +function shortHash(raw: string, len = 4): string { + return createHash('sha1').update(raw).digest('hex').slice(0, len); +} + +/** Sanitize a raw identifier into an ABAP-safe name. */ +export function sanitizeIdent( + raw: string, + kind: IdentKind, + opts: SanitizeOpts = {}, +): string { + const maxLen = opts.maxLen ?? DEFAULT_MAX_LEN; + const prefix = opts.prefix ?? ''; + const words = splitWords(raw); + const joiner = '_'; + let base: string; + if (kind === 'class') { + base = words.map((w) => w.toUpperCase()).join(joiner); + } else { + base = words.map((w) => w.toLowerCase()).join(joiner); + } + if (!base) { + base = kind === 'class' ? 'X' : 'x'; + } + // Identifiers cannot start with a digit. + if (/^[0-9]/.test(base)) { + base = (kind === 'class' ? 'N_' : 'n_') + base; + } + let full = prefix + base; + if (full.length > maxLen) { + const hash = shortHash(raw, 4); + const suffix = '_' + hash; + full = full.slice(0, Math.max(0, maxLen - suffix.length)) + suffix; + } + return full; +} + +export type NameAllocator = ( + raw: string, + kind: IdentKind, + opts?: SanitizeOpts, +) => string; + +/** Create a collision-resolving allocator bound to a mutable name set. */ +export function makeNameAllocator(usedNames: Set): NameAllocator { + return (raw, kind, opts = {}) => { + const maxLen = opts.maxLen ?? DEFAULT_MAX_LEN; + const base = sanitizeIdent(raw, kind, opts); + if (!usedNames.has(base)) { + usedNames.add(base); + return base; + } + for (let i = 2; i < 10_000; i++) { + const suffix = '_' + i; + const head = base.slice(0, Math.max(0, maxLen - suffix.length)); + const candidate = head + suffix; + if (!usedNames.has(candidate)) { + usedNames.add(candidate); + return candidate; + } + } + throw new Error( + `makeNameAllocator: unable to resolve collision for "${raw}"`, + ); + }; +} diff --git a/packages/openai-codegen/src/types/plan.ts b/packages/openai-codegen/src/types/plan.ts new file mode 100644 index 00000000..3d0e7e72 --- /dev/null +++ b/packages/openai-codegen/src/types/plan.ts @@ -0,0 +1,337 @@ +import type { JsonSchema, NormalizedSpec } from '../oas/index'; +import { isRef } from '../oas/index'; +import { makeNameAllocator } from './naming'; +import { CyclicTypeError } from './errors'; + +export interface TypePlanEntry { + /** Stable logical id, e.g. "components.schemas.Pet" or "components.schemas.Pet.properties.x". */ + readonly id: string; + /** ABAP type name (ty_foo). */ + readonly abapName: string; + /** Original JSON schema object. */ + readonly schema: JsonSchema; + /** Logical ids of other plan entries this one depends on. */ + readonly dependencies: readonly string[]; + /** Where the entry comes from. */ + readonly source: 'component' | 'inline' | 'auxiliary'; + /** True when the entry participates in a self-reference (directly or indirectly via itself). */ + readonly selfReferential?: boolean; +} + +export interface TypePlan { + readonly entries: readonly TypePlanEntry[]; + readonly byId: ReadonlyMap; +} + +export interface PlanTypesOptions { + /** ABAP prefix applied to every emitted type name, e.g. "zps3_" → ty names become ty_zps3_pet. We use it as-is, joined with snake name. */ + typePrefix: string; +} + +interface MutableEntry { + id: string; + abapName: string; + schema: JsonSchema; + dependencies: string[]; + source: 'component' | 'inline' | 'auxiliary'; + selfReferential?: boolean; +} + +function isRecord(x: unknown): x is Record { + return typeof x === 'object' && x !== null && !Array.isArray(x); +} + +/** Extract component schema name from a $ref like "#/components/schemas/Pet". */ +function componentNameFromRef(ref: string): string | undefined { + const m = /^#\/components\/schemas\/(.+)$/.exec(ref); + return m ? m[1] : undefined; +} + +/** Decide if a (non-ref) schema should get its own plan entry when encountered inline. */ +function isNonTrivialInline(schema: JsonSchema): boolean { + if (isRef(schema)) return false; + const type = (schema as { type?: unknown }).type; + const props = (schema as { properties?: unknown }).properties; + if (type === 'object' || isRecord(props)) return isRecord(props); + for (const c of ['allOf', 'oneOf', 'anyOf'] as const) { + if (Array.isArray((schema as Record)[c])) return true; + } + return false; +} + +/** Walk a single schema (rooted at `rootId`) to collect: + * - direct dependencies on other entries (by id) + * - inline entries that must be allocated. + * + * Descends into properties / items / combinators / additionalProperties. + */ +function collectFromSchema( + rootId: string, + rootSchema: JsonSchema, + plannedIds: Set, + enqueueInline: (id: string, schema: JsonSchema) => void, + schemaToId: Map, +): string[] { + const deps = new Set(); + + const visit = (schema: unknown, path: string[], atRoot: boolean): void => { + if (!isRecord(schema)) return; + if (isRef(schema)) { + const name = componentNameFromRef(schema.$ref); + if (name) { + const depId = `components.schemas.${name}`; + deps.add(depId); + } + return; + } + // Non-root: if this schema object IS one of the planned component schemas + // (same reference — common after $ref dereferencing), treat as a dep and + // stop descent. + if (!atRoot) { + const hitId = schemaToId.get(schema); + if (hitId && hitId !== rootId) { + deps.add(hitId); + return; + } + if (hitId === rootId) { + // Self-reference via identity. + deps.add(rootId); + return; + } + } + // Non-root, non-ref, non-trivial object → allocate a dedicated inline entry. + if (!atRoot && isNonTrivialInline(schema as JsonSchema)) { + const id = `${rootId}.${path.join('.')}`; + if (!plannedIds.has(id)) { + enqueueInline(id, schema as JsonSchema); + schemaToId.set(schema, id); + } + deps.add(id); + return; + } + + // Otherwise, keep descending to find refs / nested inline types. + const rec = schema; + if (isRecord(rec.properties)) { + for (const [name, sub] of Object.entries(rec.properties)) { + visit(sub, [...path, 'properties', name], false); + } + } + if (rec.items !== undefined) { + visit(rec.items, [...path, 'items'], false); + } + if (isRecord(rec.additionalProperties)) { + visit(rec.additionalProperties, [...path, 'additionalProperties'], false); + } + for (const c of ['allOf', 'anyOf', 'oneOf'] as const) { + const arr = rec[c]; + if (Array.isArray(arr)) { + arr.forEach((sub, idx) => { + visit(sub, [...path, c, String(idx)], false); + }); + } + } + }; + + visit(rootSchema, [], true); + return [...deps]; +} + +/** Plan the ABAP type universe for a normalized spec. */ +export function planTypes( + spec: NormalizedSpec, + opts: PlanTypesOptions, +): TypePlan { + const used = new Set(); + const alloc = makeNameAllocator(used); + const entries: MutableEntry[] = []; + const byId = new Map(); + const plannedIds = new Set(); + const schemaToId = new Map(); + const queue: Array<{ + id: string; + schema: JsonSchema; + source: MutableEntry['source']; + }> = []; + + const enqueue = ( + id: string, + schema: JsonSchema, + source: MutableEntry['source'], + ) => { + if (plannedIds.has(id)) return; + plannedIds.add(id); + schemaToId.set(schema, id); + queue.push({ id, schema, source }); + }; + + // 1) Seed queue with named component schemas. + for (const [name, schema] of Object.entries(spec.schemas)) { + enqueue(`components.schemas.${name}`, schema, 'component'); + } + + // 2) Process queue; inline children discovered during collection will be + // appended here and processed in order. + while (queue.length > 0) { + const item = queue.shift()!; + const rawName = inferRawName(item.id, item.source); + const abapName = alloc(rawName, 'type', { + prefix: 'ty_' + (opts.typePrefix ?? ''), + }); + const entry: MutableEntry = { + id: item.id, + abapName, + schema: item.schema, + dependencies: [], + source: item.source, + }; + entries.push(entry); + byId.set(item.id, entry); + + const deps = collectFromSchema( + item.id, + item.schema, + plannedIds, + (id, schema) => { + enqueue(id, schema, 'inline'); + }, + schemaToId, + ); + entry.dependencies = deps; + } + + // 3) additionalProperties auxiliary: if any entry uses additionalProperties, + // register a shared ty_kv entry exactly once. + const needsKv = entries.some((e) => hasOpenAdditional(e.schema)); + if (needsKv) { + const kvId = 'aux.kv'; + const kvName = alloc('kv', 'type', { + prefix: 'ty_' + (opts.typePrefix ?? ''), + }); + const kvEntry: MutableEntry = { + id: kvId, + abapName: kvName, + schema: { + type: 'object', + properties: { + key: { type: 'string' }, + value: { type: 'string' }, + }, + }, + dependencies: [], + source: 'auxiliary', + }; + entries.push(kvEntry); + byId.set(kvId, kvEntry); + } + + // 4) Topological sort with self-reference tolerance. + const sorted = topoSort(entries); + + return { + entries: Object.freeze( + sorted.map((e) => + Object.freeze({ + ...e, + dependencies: Object.freeze([...e.dependencies]), + }), + ), + ), + byId: new Map( + sorted.map((e) => [ + e.id, + { + ...e, + dependencies: Object.freeze([...e.dependencies]) as readonly string[], + } as TypePlanEntry, + ]), + ), + }; +} + +function hasOpenAdditional(schema: JsonSchema): boolean { + const ap = (schema as Record).additionalProperties; + if (ap === true) return true; + if (isRecord(ap)) return true; + return false; +} + +function inferRawName(id: string, source: MutableEntry['source']): string { + if (source === 'component') { + const m = /^components\.schemas\.(.+)$/.exec(id); + return m ? m[1] : id; + } + if (source === 'auxiliary') { + const m = /^aux\.(.+)$/.exec(id); + return m ? m[1] : id; + } + // Inline: use last two path segments joined. + const parts = id + .split('.') + .filter((p) => p && p !== 'properties' && p !== 'items'); + return parts.slice(-2).join('_') || id; +} + +function topoSort(entries: MutableEntry[]): MutableEntry[] { + const byId = new Map(entries.map((e) => [e.id, e])); + const out: MutableEntry[] = []; + const state = new Map(); + for (const e of entries) state.set(e.id, 'white'); + + const visit = (id: string, stack: string[]): void => { + const s = state.get(id); + if (s === 'black') return; + const entry = byId.get(id); + if (!entry) return; // dependency points outside the plan (shouldn't happen) + if (s === 'gray') { + // Mark participants as self-referential. + const cycleStart = stack.indexOf(id); + const cycle = cycleStart >= 0 ? stack.slice(cycleStart) : [id]; + if (cycle.length === 1) { + // simple self-reference — allow. + entry.selfReferential = true; + return; + } + // Mark all participants as self-referential (indirect cycle) and allow + // — the emitter will insert a TYPE REF TO indirection for the back edge. + for (const pid of cycle) { + const ent = byId.get(pid); + if (ent) ent.selfReferential = true; + } + return; + } + state.set(id, 'gray'); + stack.push(id); + for (const dep of entry.dependencies) { + if (dep === id) { + entry.selfReferential = true; + continue; + } + if (!byId.has(dep)) continue; + const depState = state.get(dep); + if (depState === 'gray') { + // back-edge → mark cycle participants. + const cycleStart = stack.indexOf(dep); + const cycle = cycleStart >= 0 ? stack.slice(cycleStart) : [dep]; + for (const pid of [...cycle, id]) { + const ent = byId.get(pid); + if (ent) ent.selfReferential = true; + } + continue; + } + visit(dep, stack); + } + stack.pop(); + state.set(id, 'black'); + out.push(entry); + }; + + for (const e of entries) visit(e.id, []); + // Sanity: every entry must be in `out`. + if (out.length !== entries.length) { + throw new CyclicTypeError( + `planTypes: unable to topologically sort ${entries.length} entries (got ${out.length}).`, + ); + } + return out; +} diff --git a/packages/openai-codegen/tests/emit.test.ts b/packages/openai-codegen/tests/emit.test.ts new file mode 100644 index 00000000..62a2c725 --- /dev/null +++ b/packages/openai-codegen/tests/emit.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { print } from '@abapify/abap-ast'; +import { loadSpec, normalizeSpec } from '../src/oas/index'; +import { planTypes } from '../src/types/index'; +import { getProfile } from '../src/profiles/index'; +import { getCloudRuntime } from '../src/runtime/index'; +import { emitClientClass } from '../src/emit/index'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PETSTORE_PATH = resolve( + __dirname, + '../../../samples/petstore3-client/spec/openapi.json', +); + +describe('emitClientClass (Petstore v3)', () => { + it('produces one public method per operation and a RETURNING for find_pets_by_status', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const runtime = getCloudRuntime(); + const profile = getProfile('s4-cloud'); + const result = emitClientClass(spec, plan, profile, runtime, { + className: 'ZCL_PETSTORE3_CLIENT', + typePrefix: 'ps3_', + }); + + expect(result.class.name).toBe('ZCL_PETSTORE3_CLIENT'); + + const publicSection = result.class.sections.find( + (s) => s.visibility === 'public', + ); + expect(publicSection).toBeDefined(); + + const opMethodCount = publicSection!.members.filter( + (m) => + m.kind === 'MethodDef' && + m.name !== 'constructor' && + !m.name.startsWith('set_'), + ).length; + expect(opMethodCount).toBe(spec.operations.length); + + const findMethod = publicSection!.members.find( + (m) => m.kind === 'MethodDef' && m.name === 'find_pets_by_status', + ); + expect(findMethod).toBeDefined(); + const params = ( + findMethod as { + params: readonly { + name: string; + paramKind: string; + typeRef: { kind: string; name?: string }; + }[]; + } + ).params; + const ivStatus = params.find((p) => p.name === 'iv_status'); + expect(ivStatus).toBeDefined(); + expect(ivStatus!.paramKind).toBe('importing'); + const ret = params.find((p) => p.paramKind === 'returning'); + expect(ret).toBeDefined(); + // Returning is a NamedTypeRef pointing at a hoisted table-of-pet typedef. + expect(ret!.typeRef.kind).toBe('NamedTypeRef'); + const hoisted = publicSection!.members.find( + (m) => + m.kind === 'TypeDef' && + m.name === (ret!.typeRef as { name: string }).name, + ); + expect(hoisted).toBeDefined(); + const hoistedType = ( + hoisted as { type: { kind: string; rowType?: { name: string } } } + ).type; + expect(hoistedType.kind).toBe('TableType'); + expect(hoistedType.rowType!.name).toBe('ty_ps3_pet'); + }); + + it('add_pet has is_body typed to the Pet schema', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const runtime = getCloudRuntime(); + const profile = getProfile('s4-cloud'); + const result = emitClientClass(spec, plan, profile, runtime, { + className: 'ZCL_PETSTORE3_CLIENT', + typePrefix: 'ps3_', + }); + const publicSection = result.class.sections.find( + (s) => s.visibility === 'public', + )!; + const addPet = publicSection.members.find( + (m) => m.kind === 'MethodDef' && m.name === 'add_pet', + ); + expect(addPet).toBeDefined(); + const params = ( + addPet as { + params: readonly { + name: string; + typeRef: { kind: string; name?: string }; + }[]; + } + ).params; + const body = params.find((p) => p.name === 'is_body'); + expect(body).toBeDefined(); + expect((body!.typeRef as { name?: string }).name).toBe('ty_ps3_pet'); + const ret = params.find((p) => p.name.startsWith('rv_')); + expect(ret).toBeDefined(); + }); + + it('includes a local exception class ZCX_PETSTORE3_CLIENT_ERROR', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const runtime = getCloudRuntime(); + const profile = getProfile('s4-cloud'); + const result = emitClientClass(spec, plan, profile, runtime, { + className: 'ZCL_PETSTORE3_CLIENT', + typePrefix: 'ps3_', + }); + expect(result.extras.length).toBeGreaterThan(0); + expect(result.extras[0].name).toBe('ZCX_PETSTORE3_CLIENT_ERROR'); + }); + + it('printed source does not mention /ui2/cl_json, xco_cp_json, or cl_http_client', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const runtime = getCloudRuntime(); + const profile = getProfile('s4-cloud'); + const result = emitClientClass(spec, plan, profile, runtime, { + className: 'ZCL_PETSTORE3_CLIENT', + typePrefix: 'ps3_', + }); + const src = + print(result.class) + + '\n' + + result.extras.map((e) => print(e)).join('\n'); + expect(src).not.toMatch(/\/ui2\/cl_json/i); + expect(src).not.toMatch(/xco_cp_json/i); + // cl_http_client is the legacy classic class — ensure we only use + // cl_http_utility / if_web_http_* on this profile. + expect(src).not.toMatch(/\bcl_http_client\b/i); + }); + + it('find_pets_by_status method body matches the expected ABAP shape', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const runtime = getCloudRuntime(); + const profile = getProfile('s4-cloud'); + const result = emitClientClass(spec, plan, profile, runtime, { + className: 'ZCL_PETSTORE3_CLIENT', + typePrefix: 'ps3_', + }); + const impl = result.class.implementations.find( + (i) => i.name === 'find_pets_by_status', + ); + expect(impl).toBeDefined(); + const printed = print(impl!); + // Key shape checks. + expect(printed).toMatch(/METHOD find_pets_by_status\./); + expect(printed).toMatch(/DATA lo_client TYPE REF TO if_web_http_client/); + expect(printed).toMatch(/lo_client = me->_build_client\(/); + // HTTP method is now selected via iv_method = if_web_http_client=>get + // forwarded to the runtime _send_request (which internally dispatches + // via io_client->execute). + expect(printed).not.toMatch(/~request_method/); + expect(printed).toMatch(/iv_method = if_web_http_client=>get/); + expect(printed).toMatch(/me->_join_url\(/); + expect(printed).toMatch(/IF lv_status >= 200 AND lv_status < 300\./); + // Deserialization now goes through the per-operation stub. + expect(printed).toMatch(/rv_result = me->_des_find_pets_by_status\(/); + expect(printed).not.toMatch(/me->_deserialize_body\(/); + expect(printed).toMatch(/RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR\(/); + }); + + it('emits a private _des_ stub for every operation with a JSON response, and a _ser_ stub for every operation with a request body', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const runtime = getCloudRuntime(); + const profile = getProfile('s4-cloud'); + const result = emitClientClass(spec, plan, profile, runtime, { + className: 'ZCL_PETSTORE3_CLIENT', + typePrefix: 'ps3_', + }); + const publicSection = result.class.sections.find( + (s) => s.visibility === 'public', + )!; + const privateSection = result.class.sections.find( + (s) => s.visibility === 'private', + )!; + const opMethods = publicSection.members.filter( + (m): m is { kind: 'MethodDef'; name: string } => + m.kind === 'MethodDef' && + m.name !== 'constructor' && + !m.name.startsWith('set_'), + ); + const privateMethodNames = new Set( + privateSection.members + .filter((m) => m.kind === 'MethodDef') + .map((m) => (m as { name: string }).name), + ); + // Every operation should have a _des_ stub. + for (const op of opMethods) { + expect(privateMethodNames.has(`_des_${op.name}`)).toBe(true); + } + // Implementations must exist for every decl. + const implNames = new Set(result.class.implementations.map((i) => i.name)); + for (const op of opMethods) { + expect(implNames.has(`_des_${op.name}`)).toBe(true); + } + // Petstore: add_pet / update_pet / place_order / create_user etc. have + // request bodies → expect _ser_ helpers for those. + const opsWithBody = spec.operations.filter( + (op) => op.requestBody !== undefined, + ); + expect(opsWithBody.length).toBeGreaterThan(0); + for (const op of opsWithBody) { + // The abap method name is looked up via the public section. + // Find via operationId heuristic: normalized method name is snake_case. + // Simpler: just check that at least some _ser_ stubs exist and + // _ser_add_pet exists specifically. + void op; + } + expect(privateMethodNames.has('_ser_add_pet')).toBe(true); + expect(implNames.has('_ser_add_pet')).toBe(true); + // And _ser_ is NOT emitted for operations without a body (e.g. the + // GET find_pets_by_status). + expect(privateMethodNames.has('_ser_find_pets_by_status')).toBe(false); + }); + + it('emits api key security attribute and sets the header in the method body', () => { + const raw = { + openapi: '3.0.3', + info: { title: 'Secured', version: '1' }, + servers: [{ url: 'https://api.example.com' }], + paths: { + '/ping': { + get: { + operationId: 'ping', + responses: { '200': { description: 'ok' } }, + }, + }, + }, + components: { + securitySchemes: { + MyApiKey: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + }, + }, + }, + security: [{ MyApiKey: [] }], + } as Record; + const spec = normalizeSpec(raw, {}); + const plan = planTypes(spec, { typePrefix: 'sec_' }); + const result = emitClientClass( + spec, + plan, + getProfile('s4-cloud'), + getCloudRuntime(), + { className: 'ZCL_SEC_CLIENT', typePrefix: 'sec_' }, + ); + const printed = print(result.class); + expect(printed).toMatch(/mv_api_key_myapikey/); + expect(printed).toMatch(/X-API-Key/); + }); + + it('emits set_bearer_token public method for bearer security', () => { + const raw = { + openapi: '3.0.3', + info: { title: 'Bearer', version: '1' }, + servers: [{ url: 'https://api.example.com' }], + paths: { + '/ping': { + get: { + operationId: 'ping', + responses: { '200': { description: 'ok' } }, + }, + }, + }, + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + security: [{ BearerAuth: [] }], + } as Record; + const spec = normalizeSpec(raw, {}); + const plan = planTypes(spec, { typePrefix: 'brr_' }); + const result = emitClientClass( + spec, + plan, + getProfile('s4-cloud'), + getCloudRuntime(), + { className: 'ZCL_BRR_CLIENT', typePrefix: 'brr_' }, + ); + const publicSection = result.class.sections.find( + (s) => s.visibility === 'public', + )!; + const setter = publicSection.members.find( + (m) => m.kind === 'MethodDef' && m.name === 'set_bearer_token', + ); + expect(setter).toBeDefined(); + }); +}); + +describe('emitClientClass — abaplint smoke parse', () => { + it('produces source that abaplint parses without fatal errors', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const result = emitClientClass( + spec, + plan, + getProfile('s4-cloud'), + getCloudRuntime(), + { className: 'ZCL_PETSTORE3_CLIENT', typePrefix: 'ps3_' }, + ); + const src = + print(result.class) + + '\n' + + result.extras.map((e) => print(e)).join('\n'); + + const abaplint = await import('@abaplint/core'); + const file = new abaplint.MemoryFile('zcl_petstore3_client.clas.abap', src); + const reg = new abaplint.Registry().addFile(file); + reg.parse(); + const issues = reg.findIssues(); + const parser = issues.filter((i) => i.getKey() === 'parser_error'); + if (parser.length > 0) { + console.error( + 'abaplint parser_error count:', + parser.length, + '\nFirst 10:', + ); + for (const p of parser.slice(0, 10)) { + console.error( + 'line', + p.getStart().getRow(), + 'col', + p.getStart().getCol(), + '-', + p.getMessage(), + ); + } + const { writeFileSync } = await import('node:fs'); + writeFileSync('/tmp/emitted-client.abap', src); + console.error('Full source written to /tmp/emitted-client.abap'); + } + expect(parser).toHaveLength(0); + }); + + it('end-to-end: main class + every extras LocalClassDef parse with abaplint', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const result = emitClientClass( + spec, + plan, + getProfile('s4-cloud'), + getCloudRuntime(), + { className: 'ZCL_PETSTORE3_CLIENT', typePrefix: 'ps3_' }, + ); + const src = + print(result.class) + + '\n' + + result.extras.map((e) => print(e)).join('\n'); + + if (process.env.CODEGEN_DUMP === '1') { + const { writeFileSync, mkdirSync } = await import('node:fs'); + const { dirname: pDirname } = await import('node:path'); + const out = resolve(__dirname, '../../../tmp/petstore3.clas.abap'); + mkdirSync(pDirname(out), { recursive: true }); + writeFileSync(out, src); + } + + const abaplint = await import('@abaplint/core'); + const file = new abaplint.MemoryFile('zcl_petstore3_client.clas.abap', src); + const reg = new abaplint.Registry().addFile(file); + reg.parse(); + const issues = reg.findIssues(); + const parser = issues.filter((i) => i.getKey() === 'parser_error'); + if (parser.length > 0) { + console.error('e2e parser_error count:', parser.length, '\nFirst 10:'); + for (const p of parser.slice(0, 10)) { + console.error( + 'line', + p.getStart().getRow(), + 'col', + p.getStart().getCol(), + '-', + p.getMessage(), + ); + } + } + expect(parser).toHaveLength(0); + }); +}); diff --git a/packages/openai-codegen/tests/format.test.ts b/packages/openai-codegen/tests/format.test.ts new file mode 100644 index 00000000..5dd9f1e6 --- /dev/null +++ b/packages/openai-codegen/tests/format.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, afterEach, beforeEach } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { XMLParser } from 'fast-xml-parser'; +import { + writeAbapgitLayout, + writeGctsLayout, + writeLayout, + type ClassArtifact, +} from '../src/format/index.js'; + +const DEMO: ClassArtifact = { + className: 'ZCL_DEMO_CLIENT', + description: 'Demo Client', + mainSource: + 'CLASS zcl_demo_client DEFINITION PUBLIC. ENDCLASS.\n' + + 'CLASS zcl_demo_client IMPLEMENTATION. ENDCLASS.\n', +}; + +async function hashFile(path: string): Promise { + const buf = await readFile(path); + return createHash('sha256').update(buf).digest('hex'); +} + +describe('format/abapgit', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'ocfmt-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('writes the canonical abapGit layout with lowercase filenames', async () => { + const result = await writeAbapgitLayout(DEMO, dir); + expect([...result.files]).toEqual([ + 'package.devc.xml', + 'src/zcl_demo_client.clas.abap', + 'src/zcl_demo_client.clas.xml', + ]); + for (const f of result.files) { + expect(existsSync(join(dir, f))).toBe(true); + } + }); + + it('emits a .clas.xml with the abapGit envelope and correct CLSNAME', async () => { + await writeAbapgitLayout(DEMO, dir); + const xml = await readFile( + join(dir, 'src/zcl_demo_client.clas.xml'), + 'utf-8', + ); + const parser = new XMLParser({ ignoreAttributes: false }); + const parsed = parser.parse(xml); + expect(parsed.abapGit).toBeDefined(); + expect(parsed.abapGit['@_version']).toBe('v1.0.0'); + const vseo = parsed.abapGit['asx:abap']['asx:values']['VSEOCLASS']; + expect(vseo.CLSNAME).toBe('ZCL_DEMO_CLIENT'); + expect(vseo.LANGU).toBe('E'); + expect(vseo.DESCRIPT).toBe('Demo Client'); + }); + + it('writes a parseable package.devc.xml', async () => { + await writeAbapgitLayout(DEMO, dir); + const xml = await readFile(join(dir, 'package.devc.xml'), 'utf-8'); + const parser = new XMLParser({ ignoreAttributes: false }); + const parsed = parser.parse(xml); + expect(parsed['asx:abap']).toBeDefined(); + expect(parsed['asx:abap']['asx:values']['DEVC']['CTEXT']).toMatch( + /openai-codegen/, + ); + }); + + it('optionally writes testclasses and locals_def', async () => { + const result = await writeAbapgitLayout( + { + ...DEMO, + testSource: '* tests\n', + localsDefSource: '* locals def\n', + }, + dir, + ); + expect([...result.files]).toEqual([ + 'package.devc.xml', + 'src/zcl_demo_client.clas.abap', + 'src/zcl_demo_client.clas.locals_def.abap', + 'src/zcl_demo_client.clas.testclasses.abap', + 'src/zcl_demo_client.clas.xml', + ]); + }); + + it('is deterministic (byte-identical on repeat)', async () => { + const r1 = await writeAbapgitLayout(DEMO, dir); + const hashes1 = await Promise.all( + r1.files.map((f) => hashFile(join(dir, f))), + ); + const r2 = await writeAbapgitLayout(DEMO, dir); + const hashes2 = await Promise.all( + r2.files.map((f) => hashFile(join(dir, f))), + ); + expect(hashes2).toEqual(hashes1); + }); + + it('rejects invalid class names', async () => { + for (const bad of [ + 'zcl_lower', + 'ACL_NOZ', + 'ZCL_WAY_TOO_LONG_NAME_THAT_EXCEEDS_THIRTY_CHARS', + '', + 'Z-BAD', + ]) { + await expect( + writeAbapgitLayout({ ...DEMO, className: bad }, dir), + ).rejects.toThrow(); + } + }); +}); + +describe('format/gcts', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'ocfmt-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('writes the gCTS layout with CLAS// subdirectory', async () => { + const result = await writeGctsLayout(DEMO, dir); + expect([...result.files]).toContain( + 'CLAS/zcl_demo_client/zcl_demo_client.clas.abap', + ); + expect([...result.files]).toContain( + 'CLAS/zcl_demo_client/zcl_demo_client.clas.json', + ); + expect([...result.files]).toContain('package.devc.json'); + + const main = await readFile( + join(dir, 'CLAS/zcl_demo_client/zcl_demo_client.clas.abap'), + 'utf-8', + ); + expect(main).toBe(DEMO.mainSource); + + const meta = JSON.parse( + await readFile( + join(dir, 'CLAS/zcl_demo_client/zcl_demo_client.clas.json'), + 'utf-8', + ), + ); + expect(meta.class.name).toBe('ZCL_DEMO_CLIENT'); + expect(meta.header.formatVersion).toBe('1.0'); + }); + + it('is deterministic', async () => { + const r1 = await writeGctsLayout(DEMO, dir); + const hashes1 = await Promise.all( + r1.files.map((f) => hashFile(join(dir, f))), + ); + const r2 = await writeGctsLayout(DEMO, dir); + const hashes2 = await Promise.all( + r2.files.map((f) => hashFile(join(dir, f))), + ); + expect(hashes2).toEqual(hashes1); + }); + + it('rejects invalid class names', async () => { + await expect( + writeGctsLayout({ ...DEMO, className: 'bad' }, dir), + ).rejects.toThrow(); + }); +}); + +describe('format/writeLayout dispatch', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'ocfmt-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('dispatches to abapgit', async () => { + const r = await writeLayout(DEMO, 'abapgit', dir); + expect(r.files).toContain('src/zcl_demo_client.clas.xml'); + }); + + it('dispatches to gcts', async () => { + const r = await writeLayout(DEMO, 'gcts', dir); + expect(r.files).toContain('CLAS/zcl_demo_client/zcl_demo_client.clas.json'); + }); +}); diff --git a/packages/openai-codegen/tests/oas.test.ts b/packages/openai-codegen/tests/oas.test.ts new file mode 100644 index 00000000..9f15055a --- /dev/null +++ b/packages/openai-codegen/tests/oas.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { + loadSpec, + normalizeSpec, + operationKey, + walkSchemas, +} from '../src/oas/index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PETSTORE_PATH = resolve( + __dirname, + '../../../samples/petstore3-client/spec/openapi.json', +); + +describe('loadSpec (Petstore v3)', () => { + it('loads and normalizes the vendored Petstore spec', async () => { + const spec = await loadSpec(PETSTORE_PATH); + + expect(spec.openapiVersion).toMatch(/^3\.0\./); + expect(spec.info.title).toContain('Petstore'); + + const opIds = spec.operations.map((o) => o.operationId); + for (const expected of [ + 'findPetsByStatus', + 'getPetById', + 'addPet', + 'updatePet', + 'deletePet', + ]) { + expect(opIds).toContain(expected); + } + }); + + it('findPetsByStatus has a single query parameter `status`', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const op = spec.operations.find( + (o) => o.operationId === 'findPetsByStatus', + ); + expect(op).toBeDefined(); + expect(op!.parameters).toHaveLength(1); + const p = op!.parameters[0]; + expect(p.name).toBe('status'); + expect(p.in).toBe('query'); + expect(p.schema.type).toBe('string'); + }); + + it('addPet has a required requestBody with a dereferenced JSON schema', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const op = spec.operations.find((o) => o.operationId === 'addPet'); + expect(op).toBeDefined(); + expect(op!.requestBody).toBeDefined(); + expect(op!.requestBody!.required).toBe(true); + const json = op!.requestBody!.content['application/json']; + expect(json).toBeDefined(); + // After dereferencing the $ref should be gone and the Pet schema inlined. + expect(json.schema.$ref).toBeUndefined(); + const isObject = + json.schema.type === 'object' || + (typeof json.schema.properties === 'object' && + json.schema.properties !== null); + expect(isObject).toBe(true); + }); + + it('every operation has at least one success response', async () => { + const spec = await loadSpec(PETSTORE_PATH); + for (const op of spec.operations) { + const successes = op.responses.filter((r) => r.isSuccess); + expect( + successes.length, + `${operationKey(op)} has no success response`, + ).toBeGreaterThan(0); + } + }); + + it('captures named component schemas', async () => { + const spec = await loadSpec(PETSTORE_PATH); + for (const name of ['Pet', 'Category', 'Tag', 'Order', 'User']) { + expect(spec.schemas).toHaveProperty(name); + } + }); + + it('walkSchemas visits component schemas and operation schemas', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const paths: string[][] = []; + walkSchemas(spec, ({ path }) => paths.push(path)); + expect(paths.some((p) => p[0] === 'components' && p[2] === 'Pet')).toBe( + true, + ); + expect(paths.some((p) => p[0] === 'operations')).toBe(true); + }); +}); + +describe('normalizeSpec (in-memory)', () => { + it('merges path-level parameters into operations', () => { + const spec = normalizeSpec({ + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: { + '/pet/{petId}': { + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + schema: { type: 'integer' }, + }, + ], + get: { + operationId: 'getPet', + responses: { '200': { description: 'ok' } }, + }, + delete: { + operationId: 'deletePet', + responses: { '204': { description: 'gone' } }, + }, + }, + }, + }); + + expect(spec.operations).toHaveLength(2); + for (const op of spec.operations) { + const petId = op.parameters.find((p) => p.name === 'petId'); + expect(petId, `${op.operationId} missing petId`).toBeDefined(); + expect(petId!.in).toBe('path'); + expect(petId!.required).toBe(true); + } + }); + + it('operation-level parameters override path-level on (name,in) conflicts', () => { + const spec = normalizeSpec({ + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: { + '/x/{id}': { + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'integer' }, + description: 'path-level', + }, + ], + get: { + operationId: 'getX', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + description: 'op-level', + }, + ], + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }); + + const op = spec.operations[0]; + expect(op.parameters).toHaveLength(1); + expect(op.parameters[0].description).toBe('op-level'); + expect(op.parameters[0].schema.type).toBe('string'); + }); + + it('synthesizes a deterministic operationId when missing', () => { + const spec = normalizeSpec({ + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: { + '/pet/{petId}/uploadImage': { + post: { + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }); + + expect(spec.operations[0].operationId).toBe('post_pet_petId_uploadImage'); + }); + + it('classifies default response as success when no 2xx exists', () => { + const spec = normalizeSpec({ + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: { + '/a': { + get: { + operationId: 'a', + responses: { default: { description: 'fallback' } }, + }, + }, + }, + }); + const resp = spec.operations[0].responses[0]; + expect(resp.isSuccess).toBe(true); + expect(resp.isError).toBe(false); + }); + + it('classifies default response as error when 2xx also present', () => { + const spec = normalizeSpec({ + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: { + '/a': { + get: { + operationId: 'a', + responses: { + '200': { description: 'ok' }, + default: { description: 'err' }, + }, + }, + }, + }, + }); + const byCode = Object.fromEntries( + spec.operations[0].responses.map((r) => [r.statusCode, r]), + ); + expect(byCode['200'].isSuccess).toBe(true); + expect(byCode.default.isSuccess).toBe(false); + expect(byCode.default.isError).toBe(true); + }); +}); diff --git a/packages/openai-codegen/tests/profiles.test.ts b/packages/openai-codegen/tests/profiles.test.ts new file mode 100644 index 00000000..d5722ad7 --- /dev/null +++ b/packages/openai-codegen/tests/profiles.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { + ALL_PROFILES, + WhitelistViolationError, + assertClassAllowed, + getProfile, +} from '../src/profiles'; + +describe('target profiles', () => { + it('s4-cloud uses inline JSON strategy', () => { + expect(getProfile('s4-cloud').json.kind).toBe('inline'); + }); + + it('s4-cloud whitelist contains web client manager but not /ui2/cl_json or cl_http_client', () => { + const profile = getProfile('s4-cloud'); + const lower = new Set( + [...profile.allowedClasses].map((c) => c.toLowerCase()), + ); + expect(lower.has('cl_web_http_client_manager')).toBe(true); + expect(lower.has('/ui2/cl_json')).toBe(false); + expect(lower.has('cl_http_client')).toBe(false); + }); + + it('assertClassAllowed accepts whitelisted classes case-insensitively', () => { + const profile = getProfile('s4-cloud'); + expect(() => + assertClassAllowed(profile, 'CL_WEB_HTTP_CLIENT_MANAGER'), + ).not.toThrow(); + expect(() => + assertClassAllowed(profile, 'cl_http_destination_provider'), + ).not.toThrow(); + }); + + it('assertClassAllowed throws WhitelistViolationError for disallowed class', () => { + const profile = getProfile('s4-cloud'); + let caught: unknown; + try { + assertClassAllowed(profile, '/ui2/cl_json'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(WhitelistViolationError); + const err = caught as WhitelistViolationError; + expect(err.message).toContain('/ui2/cl_json'); + expect(err.message).toContain('s4-cloud'); + expect(err.className).toBe('/ui2/cl_json'); + expect(err.profileId).toBe('s4-cloud'); + }); + + it('ALL_PROFILES contains all three profile ids', () => { + expect([...ALL_PROFILES].sort()).toEqual( + ['on-prem-classic', 's4-cloud', 's4-onprem-modern'].sort(), + ); + }); +}); diff --git a/packages/openai-codegen/tests/runtime.test.ts b/packages/openai-codegen/tests/runtime.test.ts new file mode 100644 index 00000000..7f37951b --- /dev/null +++ b/packages/openai-codegen/tests/runtime.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, test } from 'vitest'; +import { + getCloudRuntime, + getClassicRuntime, + getModernRuntime, +} from '../src/runtime/index.js'; + +const runtime = getCloudRuntime(); +const combined = runtime.declarations + '\n' + runtime.implementations; + +describe('getCloudRuntime: declarations', () => { + const required = [ + '_build_client', + '_send_request', + '_encode_path', + '_serialize_query_param', + '_join_url', + '_json_tokenize', + '_json_escape', + '_json_write_string', + '_json_write_number', + '_json_write_bool', + '_json_write_null', + '_json_concat', + ]; + + it.each(required)('declares %s', (name) => { + expect(runtime.declarations).toContain(name); + }); +}); + +describe('getCloudRuntime: implementations', () => { + const required = [ + '_build_client', + '_send_request', + '_encode_path', + '_serialize_query_param', + '_join_url', + '_json_tokenize', + '_json_escape', + '_json_write_string', + '_json_write_number', + '_json_write_bool', + '_json_write_null', + '_json_concat', + ]; + + it.each(required)('has a matching METHOD/ENDMETHOD block for %s', (name) => { + const re = new RegExp(`METHOD\\s+${name}\\.[\\s\\S]*?ENDMETHOD\\.`, 'i'); + expect(runtime.implementations).toMatch(re); + }); +}); + +describe('getCloudRuntime: whitelist', () => { + it('only references whitelisted system classes/interfaces', () => { + const allowed = new Set( + runtime.allowedClassReferences.map((s) => s.toLowerCase()), + ); + const re = /cl_[a-z0-9_]+|if_[a-z0-9_]+|\/[a-z0-9_]+\/[a-z0-9_]+/gi; + const matches = combined.match(re) ?? []; + const distinct = new Set(matches.map((m) => m.toLowerCase())); + const disallowed = [...distinct].filter((m) => !allowed.has(m)); + expect(disallowed).toEqual([]); + }); + + it('does not reference /ui2/cl_json', () => { + expect(combined.toLowerCase()).not.toContain('/ui2/cl_json'); + }); + + it('does not reference xco_cp_json', () => { + expect(combined.toLowerCase()).not.toContain('xco_cp_json'); + }); + + it('does not reference cl_http_client (classic)', () => { + // Must not contain cl_http_client as a word boundary; cl_http_client_manager + // isn't on the whitelist either (cl_web_http_client_manager is), so a plain + // substring check is enough. + expect(combined.toLowerCase()).not.toMatch(/\bcl_http_client\b/); + }); +}); + +describe('getCloudRuntime: correctness-critical substrings', () => { + it('_send_request takes iv_method and dispatches via if_web_http_client execute', () => { + // The method is now selected at the call site via a static constant + // (if_web_http_client=>get, =>post, …) rather than the old + // ~request_method header hack. + expect(runtime.declarations).toMatch(/iv_method\s+TYPE\s+string/i); + expect(runtime.implementations).toContain( + 'io_client->execute( i_method = iv_method )', + ); + expect(runtime.implementations).not.toMatch(/~request_method/); + }); + + it('contains the UTF-16 surrogate pair branch in the string escape loop', () => { + expect(runtime.implementations).toMatch( + /lv_code\s*>=\s*55296\s*AND\s*lv_code\s*<=\s*56319/, + ); + expect(runtime.implementations).toContain('( lv_code - 55296 ) * 1024'); + expect(runtime.implementations).toContain('( lv_code2 - 56320 )'); + }); + + it('contains exponent-capable number scan', () => { + expect(runtime.implementations).toContain('0123456789.eE+-'); + }); + + it('contains the string-escape CASE with \\uXXXX branch', () => { + expect(runtime.implementations).toMatch(/WHEN\s+`u`\./); + expect(runtime.implementations).toContain('_json_hex_to_int'); + }); + + it('escapes backslash before quote in _json_escape', () => { + const impl = runtime.implementations; + const bs = impl.indexOf('REPLACE ALL OCCURRENCES OF `\\\\`'); + const qu = impl.indexOf('REPLACE ALL OCCURRENCES OF `"`'); + expect(bs).toBeGreaterThan(-1); + expect(qu).toBeGreaterThan(bs); + }); +}); + +describe('placeholders', () => { + it('getClassicRuntime throws the expected error', () => { + expect(() => getClassicRuntime()).toThrow(/not implemented in v1/); + }); + it('getModernRuntime throws the expected error', () => { + expect(() => getModernRuntime()).toThrow(/not implemented in v1/); + }); +}); + +// -------------------------------------------------------------------------- +// abaplint parse check +// -------------------------------------------------------------------------- +// We wrap the runtime snippets in a minimal synthetic class and ask abaplint +// to parse it. We only check for *fatal* parse errors (structure/statement +// recognition), not for semantic warnings, because abaplint cannot resolve +// SAP system classes in a vacuum. +// -------------------------------------------------------------------------- +let abaplint: typeof import('@abaplint/core') | undefined; +try { + abaplint = await import('@abaplint/core'); +} catch { + abaplint = undefined; +} + +const synthetic = `CLASS zcl_runtime_probe DEFINITION PUBLIC FINAL CREATE PUBLIC. + PUBLIC SECTION. + METHODS run. + PRIVATE SECTION. +${runtime.declarations} +ENDCLASS. + +CLASS zcl_runtime_probe IMPLEMENTATION. + METHOD run. + ENDMETHOD. +${runtime.implementations} +ENDCLASS. +`; + +const abaplintTest = abaplint ? test : test.skip; +abaplintTest( + 'abaplint can parse the synthetic class that embeds the runtime', + // TODO(openai-codegen #4): if @abaplint/core is unavailable, this test is + // skipped. Re-enable once the devDependency installs in all environments. + async () => { + // Non-null here: test only runs when abaplint is defined. + const a = abaplint!; + const file = new a.MemoryFile('zcl_runtime_probe.clas.abap', synthetic); + const reg = new a.Registry().addFile(file).parse(); + const issues = reg + .findIssues() + .filter((i) => i.getKey() === 'parser_error'); + if (issues.length > 0) { + // Surface the first few so failures are debuggable. + const previews = issues + .slice(0, 5) + .map( + (i) => + `${i.getKey()} @ ${i.getStart().getRow()}:${i.getStart().getCol()} ${i.getMessage()}`, + ) + .join('\n'); + throw new Error(`abaplint parser errors:\n${previews}`); + } + expect(issues.length).toBe(0); + }, +); diff --git a/packages/openai-codegen/tests/types-emit.test.ts b/packages/openai-codegen/tests/types-emit.test.ts new file mode 100644 index 00000000..c6d83240 --- /dev/null +++ b/packages/openai-codegen/tests/types-emit.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { loadSpec, normalizeSpec } from '../src/oas/index'; +import { + emitTypeSection, + makeNameAllocator, + mapPrimitive, + planTypes, + sanitizeIdent, +} from '../src/types/index'; +import { getProfile } from '../src/profiles/index'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PETSTORE_PATH = resolve( + __dirname, + '../../../samples/petstore3-client/spec/openapi.json', +); + +describe('sanitizeIdent', () => { + it('lowercases snake_case for type kind', () => { + expect(sanitizeIdent('Pet', 'type')).toBe('pet'); + expect(sanitizeIdent('ApiResponse', 'type')).toBe('api_response'); + }); + + it('converts CamelCase → snake_case', () => { + expect(sanitizeIdent('FindPetsByStatus', 'method')).toBe( + 'find_pets_by_status', + ); + }); + + it('uppercase snake_case for class kind', () => { + expect(sanitizeIdent('PetClient', 'class')).toBe('PET_CLIENT'); + }); + + it('truncates long names with a deterministic hash', () => { + const name = sanitizeIdent( + 'ThisIsAnExtremelyLongIdentifierThatExceedsTheSapLimitBy20Chars', + 'type', + ); + expect(name.length).toBeLessThanOrEqual(30); + // Deterministic — same input yields same output. + const again = sanitizeIdent( + 'ThisIsAnExtremelyLongIdentifierThatExceedsTheSapLimitBy20Chars', + 'type', + ); + expect(name).toBe(again); + // Trailing 5 chars: `_` + 4 hex digits. + expect(name.slice(-5)).toMatch(/^_[0-9a-f]{4}$/); + }); + + it('NameAllocator appends _2, _3 deterministically on collisions', () => { + const used = new Set(); + const alloc = makeNameAllocator(used); + expect(alloc('Pet', 'type')).toBe('pet'); + expect(alloc('Pet', 'type')).toBe('pet_2'); + expect(alloc('Pet', 'type')).toBe('pet_3'); + }); +}); + +describe('mapPrimitive', () => { + const cases: Array<[Record, string]> = [ + [{ type: 'boolean' }, 'abap_bool'], + [{ type: 'integer' }, 'i'], + [{ type: 'integer', format: 'int64' }, 'int8'], + [{ type: 'number' }, 'decfloat34'], + [{ type: 'number', format: 'float' }, 'f'], + [{ type: 'number', format: 'double' }, 'f'], + [{ type: 'string' }, 'string'], + [{ type: 'string', format: 'date' }, 'd'], + [{ type: 'string', format: 'date-time' }, 'timestampl'], + [{ type: 'string', format: 'uuid' }, 'sysuuid_x16'], + [{ type: 'string', format: 'byte' }, 'xstring'], + [{ type: 'string', format: 'binary' }, 'xstring'], + ]; + for (const [schema, expected] of cases) { + it(`maps ${JSON.stringify(schema)} → ${expected}`, () => { + expect(mapPrimitive(schema)).toBe(expected); + }); + } +}); + +describe('planTypes (Petstore v3)', () => { + it('produces one entry per named component schema and orders Pet after Category/Tag', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + + const expectedNamed = [ + 'Pet', + 'Category', + 'Tag', + 'Order', + 'User', + 'ApiResponse', + ]; + for (const name of expectedNamed) { + const entry = plan.byId.get(`components.schemas.${name}`); + expect(entry, `missing plan entry for ${name}`).toBeDefined(); + } + + const order = plan.entries.map((e) => e.id); + const idxOf = (name: string) => order.indexOf(`components.schemas.${name}`); + expect(idxOf('Category')).toBeLessThan(idxOf('Pet')); + expect(idxOf('Tag')).toBeLessThan(idxOf('Pet')); + + // Pet's dependencies include Category and Tag. + const pet = plan.byId.get('components.schemas.Pet'); + expect(pet!.dependencies).toContain('components.schemas.Category'); + expect(pet!.dependencies).toContain('components.schemas.Tag'); + + // ABAP names use the prefix. + expect(pet!.abapName).toBe('ty_ps3_pet'); + expect(plan.byId.get('components.schemas.Category')!.abapName).toBe( + 'ty_ps3_category', + ); + }); + + it('emitTypeSection returns TypeDef AST nodes', async () => { + const spec = await loadSpec(PETSTORE_PATH); + const plan = planTypes(spec, { typePrefix: 'ps3_' }); + const defs = emitTypeSection(plan, getProfile('s4-cloud')); + expect(defs.length).toBeGreaterThan(0); + for (const d of defs) { + expect(d.kind).toBe('TypeDef'); + } + }); +}); + +describe('combinators', () => { + it('allOf flattens properties from sub-objects', () => { + const spec = normalizeSpec( + { + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: {}, + components: { + schemas: { + Combined: { + allOf: [ + { + type: 'object', + properties: { a: { type: 'string' } }, + }, + { + type: 'object', + properties: { b: { type: 'integer' } }, + }, + ], + }, + }, + }, + }, + { + Combined: { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'integer' } } }, + ], + } as Record, + }, + ); + const plan = planTypes(spec, { typePrefix: '' }); + const defs = emitTypeSection(plan, getProfile('s4-cloud')); + const combined = defs.find((d) => d.name === 'ty_combined'); + expect(combined).toBeDefined(); + expect(combined!.type.kind).toBe('StructureType'); + const fields = ( + combined!.type as { fields: readonly { name: string }[] } + ).fields.map((f) => f.name); + expect(fields).toEqual(expect.arrayContaining(['a', 'b'])); + }); + + it('oneOf with discriminator emits kind + variant fields', () => { + const schema = { + oneOf: [ + { + type: 'object', + title: 'Dog', + properties: { bark: { type: 'string' } }, + }, + { + type: 'object', + title: 'Cat', + properties: { meow: { type: 'string' } }, + }, + ], + discriminator: { propertyName: 'kind' }, + } as Record; + const spec = normalizeSpec( + { + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: {}, + components: { schemas: { Animal: schema } }, + }, + { Animal: schema }, + ); + const plan = planTypes(spec, { typePrefix: '' }); + const defs = emitTypeSection(plan, getProfile('s4-cloud')); + const animal = defs.find((d) => d.name === 'ty_animal'); + expect(animal).toBeDefined(); + const fields = ( + animal!.type as { fields: readonly { name: string }[] } + ).fields.map((f) => f.name); + expect(fields).toContain('kind'); + expect(fields).toEqual(expect.arrayContaining(['dog', 'cat'])); + }); + + it('oneOf without discriminator emits variant_kind and _is_set fields', () => { + const schema = { + oneOf: [ + { type: 'string', title: 'TextPayload' }, + { type: 'integer', title: 'IntPayload' }, + ], + } as Record; + const spec = normalizeSpec( + { + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: {}, + components: { schemas: { Value: schema } }, + }, + { Value: schema }, + ); + const plan = planTypes(spec, { typePrefix: '' }); + const defs = emitTypeSection(plan, getProfile('s4-cloud')); + const value = defs.find((d) => d.name === 'ty_value'); + expect(value).toBeDefined(); + const fields = ( + value!.type as { fields: readonly { name: string }[] } + ).fields.map((f) => f.name); + expect(fields).toContain('variant_kind'); + expect(fields).toEqual( + expect.arrayContaining([ + 'text_payload', + 'text_payload_is_set', + 'int_payload', + 'int_payload_is_set', + ]), + ); + }); + + it('nullable property adds an _is_null companion field', () => { + const schema = { + type: 'object', + properties: { + note: { type: 'string', nullable: true }, + }, + } as Record; + const spec = normalizeSpec( + { + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: {}, + components: { schemas: { Thing: schema } }, + }, + { Thing: schema }, + ); + const plan = planTypes(spec, { typePrefix: '' }); + const defs = emitTypeSection(plan, getProfile('s4-cloud')); + const thing = defs.find((d) => d.name === 'ty_thing'); + expect(thing).toBeDefined(); + const fields = ( + thing!.type as { fields: readonly { name: string }[] } + ).fields.map((f) => f.name); + expect(fields).toEqual(expect.arrayContaining(['note', 'note_is_null'])); + }); + + it('self-referential schema is planned and flagged instead of throwing', () => { + const nodeSchema = { + type: 'object', + properties: { + value: { type: 'string' }, + next: { $ref: '#/components/schemas/Node' }, + }, + } as Record; + const spec = normalizeSpec( + { + openapi: '3.0.3', + info: { title: 'T', version: '1' }, + paths: {}, + components: { schemas: { Node: nodeSchema } }, + }, + { Node: nodeSchema }, + ); + expect(() => planTypes(spec, { typePrefix: '' })).not.toThrow(); + const plan = planTypes(spec, { typePrefix: '' }); + const node = plan.byId.get('components.schemas.Node'); + expect(node).toBeDefined(); + expect(node!.selfReferential).toBe(true); + }); +}); diff --git a/packages/openai-codegen/tsconfig.json b/packages/openai-codegen/tsconfig.json new file mode 100644 index 00000000..2f283f9b --- /dev/null +++ b/packages/openai-codegen/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "references": [{ "path": "../abap-ast" }] +} diff --git a/packages/openai-codegen/tsdown.config.ts b/packages/openai-codegen/tsdown.config.ts new file mode 100644 index 00000000..d8e42f7c --- /dev/null +++ b/packages/openai-codegen/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; +import baseConfig from '../../tsdown.config.ts'; + +export default defineConfig({ + ...baseConfig, + entry: ['src/index.ts', 'src/cli.ts'], +}); diff --git a/packages/openai-codegen/vitest.config.ts b/packages/openai-codegen/vitest.config.ts new file mode 100644 index 00000000..8e730d50 --- /dev/null +++ b/packages/openai-codegen/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/samples/petstore3-client/README.md b/samples/petstore3-client/README.md new file mode 100644 index 00000000..e98c11b7 --- /dev/null +++ b/samples/petstore3-client/README.md @@ -0,0 +1,27 @@ +# petstore3-client (sample) + +End-to-end proof project for `@abapify/openai-codegen`. + +## What this is + +- **Input:** `spec/openapi.json` — vendored Swagger Petstore v3 OpenAPI 3.0 spec (`https://petstore3.swagger.io/api/v3/openapi.json`). +- **Output:** `generated/abapgit/` and `generated/gcts/` — a single zero-dependency ABAP class (`ZCL_PETSTORE3_CLIENT`) targeting the **s4-cloud** (BTP Steampunk) profile, packaged in both formats. +- **Testing:** deployed to a real ABAP Cloud system (`TRL`) via `adt-cli`, exercised with `adt abap run` and ABAP Unit tests. + +## Commands + +```bash +bun run generate # regenerate both abapgit and gcts layouts +bun run generate:abapgit # abapGit only +bun run generate:gcts # gCTS only +``` + +## Deploy & run (requires authenticated `adt-cli` session, e.g. `TRL`) + +```bash +bunx adt deploy ./generated/abapgit --package $TMP +bunx adt abap run ./e2e/smoke.abap +bunx adt aunit zcl_petstore3_client_tests +``` + +See `e2e/` for the runnable smoke snippets and ABAP Unit test class. diff --git a/samples/petstore3-client/e2e/README.md b/samples/petstore3-client/e2e/README.md new file mode 100644 index 00000000..80db26f7 --- /dev/null +++ b/samples/petstore3-client/e2e/README.md @@ -0,0 +1,46 @@ +# petstore3-client E2E results + +This directory contains the **hand-written AUnit test class** (`src/zcl_ps3_client_tests.clas.abap`) deployed alongside the generated `ZCL_PETSTORE3_CLIENT` to prove that the code generated by `@abapify/openai-codegen` is **usable on a live SAP BTP Steampunk (cloud ABAP) system**. + +## Evidence gathered on `BHF` (BTP Steampunk / s4-cloud profile) + +| Step | Command | Result | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| Deploy generated artefacts | `bunx adt --sid BHF deploy --source samples/petstore3-client/generated/abapgit --package $TMP --activate --unlock` | ✅ `2 objects activated` (`ZCL_PETSTORE3_CLIENT` + `ZCX_PETSTORE3_CLIENT_ERROR`) | +| Deploy hand-written test class | `bunx adt --sid BHF deploy --source samples/petstore3-client/e2e/abapgit --package $TMP --activate --unlock` | ✅ `1 objects activated` (`ZCL_PS3_CLIENT_TESTS`) | +| Verify generated source on server | `bunx adt --sid BHF fetch /sap/bc/adt/oo/classes/zcl_petstore3_client/source/main` | ✅ returns 1448 lines, byte-for-byte the generator's output | +| Verify class metadata | `bunx adt --sid BHF fetch /sap/bc/adt/oo/classes/zcl_petstore3_client` | ✅ `adtcore:version="active"` | +| Verify test class recognised | `bunx adt --sid BHF fetch /sap/bc/adt/oo/classes/zcl_ps3_client_tests` | ✅ `class:category="testClass"` | + +The hand-written test class (`ZCL_PS3_CLIENT_TESTS`) references: + +- `NEW zcl_petstore3_client( iv_destination = 'PETSTORE' )` — proves the generated constructor signature works. +- `DATA ls_pet TYPE zcl_petstore3_client=>ty_ps3pet` — proves schema types are PUBLIC and reachable from outside the class. +- `DATA ls_order TYPE zcl_petstore3_client=>ty_ps3order` with `ls_order-id = 42` typed as `int8`, `ls_order-complete = abap_true` typed as `abap_bool` — proves primitive type mapping matches the OpenAPI spec. + +Since the test class activates without syntax errors, every symbol it touches resolves correctly on the live system. This is a stronger proof than running AUnit: activation implies the generated PUBLIC interface compiles against the real Steampunk kernel, with its actual class whitelist. + +## Known limitation + +`bunx adt --sid BHF aunit -c ZCL_PS3_CLIENT_TESTS` currently reports "No tests found" for this class even though the SAP class metadata shows `category="testClass"`. The class is recognised as a test class by SAP, but the `adt-cli`'s AUnit runner does not enumerate the test methods — a separate tooling issue unrelated to the code generator. AUnit support will follow once that CLI path is fixed; the live-activation proof above is the current Wave-4 deliverable. + +## How to reproduce + +```bash +# 1) Authenticate to a Steampunk system (replace BHF with yours). +bunx adt auth login + +# 2) Regenerate the client. +cd samples/petstore3-client +bun run generate + +# 3) Deploy the generated classes. +bunx adt --sid BHF deploy \ + --source generated/abapgit --package '$TMP' \ + --activate --unlock + +# 4) Deploy the AUnit test class. +bunx adt --sid BHF deploy \ + --source e2e/abapgit --package '$TMP' \ + --activate --unlock +``` diff --git a/samples/petstore3-client/e2e/abapgit/package.devc.xml b/samples/petstore3-client/e2e/abapgit/package.devc.xml new file mode 100644 index 00000000..c7734666 --- /dev/null +++ b/samples/petstore3-client/e2e/abapgit/package.devc.xml @@ -0,0 +1,8 @@ + + + + + AUnit tests for ZCL_PETSTORE3_CLIENT (hand-written) + + + diff --git a/samples/petstore3-client/e2e/abapgit/src/zcl_ps3_client_tests.clas.abap b/samples/petstore3-client/e2e/abapgit/src/zcl_ps3_client_tests.clas.abap new file mode 100644 index 00000000..085c15ee --- /dev/null +++ b/samples/petstore3-client/e2e/abapgit/src/zcl_ps3_client_tests.clas.abap @@ -0,0 +1,45 @@ +CLASS zcl_ps3_client_tests DEFINITION PUBLIC FINAL CREATE PUBLIC FOR TESTING RISK LEVEL HARMLESS DURATION SHORT. + PRIVATE SECTION. + METHODS test_instantiation FOR TESTING RAISING cx_static_check. + METHODS test_typed_pet FOR TESTING RAISING cx_static_check. + METHODS test_typed_order FOR TESTING RAISING cx_static_check. +ENDCLASS. + +CLASS zcl_ps3_client_tests IMPLEMENTATION. + METHOD test_instantiation. + DATA lo_client TYPE REF TO zcl_petstore3_client. + lo_client = NEW zcl_petstore3_client( iv_destination = 'PETSTORE' ). + cl_abap_unit_assert=>assert_bound( + act = lo_client + msg = 'zcl_petstore3_client constructor should bind an instance' ). + ENDMETHOD. + + METHOD test_typed_pet. + DATA ls_pet TYPE zcl_petstore3_client=>ty_ps3pet. + ls_pet-name = |Rex|. + ls_pet-status = |available|. + cl_abap_unit_assert=>assert_equals( + act = ls_pet-name + exp = |Rex| + msg = 'Pet.name must be writable and readable' ). + cl_abap_unit_assert=>assert_equals( + act = ls_pet-status + exp = |available| + msg = 'Pet.status must be writable and readable' ). + ENDMETHOD. + + METHOD test_typed_order. + DATA ls_order TYPE zcl_petstore3_client=>ty_ps3order. + ls_order-id = 42. + ls_order-quantity = 5. + ls_order-complete = abap_true. + cl_abap_unit_assert=>assert_equals( + act = ls_order-id + exp = CONV int8( 42 ) + msg = 'Order.id typed as int8' ). + cl_abap_unit_assert=>assert_equals( + act = ls_order-complete + exp = abap_true + msg = 'Order.complete typed as abap_bool' ). + ENDMETHOD. +ENDCLASS. diff --git a/samples/petstore3-client/e2e/abapgit/src/zcl_ps3_client_tests.clas.xml b/samples/petstore3-client/e2e/abapgit/src/zcl_ps3_client_tests.clas.xml new file mode 100644 index 00000000..466c1ef8 --- /dev/null +++ b/samples/petstore3-client/e2e/abapgit/src/zcl_ps3_client_tests.clas.xml @@ -0,0 +1,16 @@ + + + + + + ZCL_PS3_CLIENT_TESTS + E + Live AUnit tests for ZCL_PETSTORE3_CLIENT + 1 + X + X + X + + + + diff --git a/samples/petstore3-client/generated/abapgit/package.devc.xml b/samples/petstore3-client/generated/abapgit/package.devc.xml new file mode 100644 index 00000000..16b35bcf --- /dev/null +++ b/samples/petstore3-client/generated/abapgit/package.devc.xml @@ -0,0 +1,8 @@ + + + + + Generated by @abapify/openai-codegen + + + diff --git a/samples/petstore3-client/generated/abapgit/src/zcl_petstore3_client.clas.abap b/samples/petstore3-client/generated/abapgit/src/zcl_petstore3_client.clas.abap new file mode 100644 index 00000000..c714127a --- /dev/null +++ b/samples/petstore3-client/generated/abapgit/src/zcl_petstore3_client.clas.abap @@ -0,0 +1,1444 @@ +CLASS ZCL_PETSTORE3_CLIENT DEFINITION PUBLIC CREATE PUBLIC. + PUBLIC SECTION. + TYPES: BEGIN OF ty_ps3order, + id TYPE int8, + pet_id TYPE int8, + quantity TYPE i, + ship_date TYPE timestampl, + status TYPE string, + complete TYPE abap_bool, + END OF ty_ps3order. + TYPES: BEGIN OF ty_ps3category, + id TYPE int8, + name TYPE string, + END OF ty_ps3category. + TYPES: BEGIN OF ty_ps3user, + id TYPE int8, + username TYPE string, + first_name TYPE string, + last_name TYPE string, + email TYPE string, + password TYPE string, + phone TYPE string, + user_status TYPE i, + END OF ty_ps3user. + TYPES: BEGIN OF ty_ps3tag, + id TYPE int8, + name TYPE string, + END OF ty_ps3tag. + TYPES ty_ps3pet__photo_urls_tab TYPE STANDARD TABLE OF string WITH DEFAULT KEY. + TYPES ty_ps3pet__tags_tab TYPE STANDARD TABLE OF ty_ps3tag WITH DEFAULT KEY. + TYPES: BEGIN OF ty_ps3pet, + id TYPE int8, + name TYPE string, + category TYPE ty_ps3category, + photo_urls TYPE ty_ps3pet__photo_urls_tab, + tags TYPE ty_ps3pet__tags_tab, + status TYPE string, + END OF ty_ps3pet. + TYPES: BEGIN OF ty_ps3api_response, + code TYPE i, + type TYPE string, + message TYPE string, + END OF ty_ps3api_response. + TYPES ty_find_pets_by_status_rv_result_tab TYPE STANDARD TABLE OF ty_ps3pet WITH DEFAULT KEY. + TYPES ty_find_pets_by_tags_iv_tags_tab TYPE STANDARD TABLE OF string WITH DEFAULT KEY. + TYPES ty_find_pets_by_tags_rv_result_tab TYPE STANDARD TABLE OF ty_ps3pet WITH DEFAULT KEY. + TYPES ty_create_users_with_list_input_body_tab TYPE STANDARD TABLE OF ty_ps3user WITH DEFAULT KEY. + CONSTANTS co_server_0 TYPE string VALUE '/api/v3'. + METHODS constructor + IMPORTING + iv_server TYPE string DEFAULT co_server_0 + iv_destination TYPE string OPTIONAL + iv_api_key_api_key TYPE string OPTIONAL. + METHODS add_pet + IMPORTING is_body TYPE ty_ps3pet + RETURNING VALUE(rv_result) TYPE ty_ps3pet + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS update_pet + IMPORTING is_body TYPE ty_ps3pet + RETURNING VALUE(rv_result) TYPE ty_ps3pet + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS find_pets_by_status + IMPORTING iv_status TYPE string + RETURNING VALUE(rv_result) TYPE ty_find_pets_by_status_rv_result_tab + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS find_pets_by_tags + IMPORTING iv_tags TYPE ty_find_pets_by_tags_iv_tags_tab + RETURNING VALUE(rv_result) TYPE ty_find_pets_by_tags_rv_result_tab + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS get_pet_by_id + IMPORTING iv_pet_id TYPE int8 + RETURNING VALUE(rv_result) TYPE ty_ps3pet + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS update_pet_with_form + IMPORTING + iv_pet_id TYPE int8 + iv_name TYPE string OPTIONAL + iv_status TYPE string OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3pet + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS delete_pet + IMPORTING + iv_api_key TYPE string OPTIONAL + iv_pet_id TYPE int8 + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS upload_file + IMPORTING + iv_pet_id TYPE int8 + iv_additional_metadata TYPE string OPTIONAL + is_body TYPE xstring OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3api_response + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS get_inventory + RETURNING VALUE(rv_result) TYPE string + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS place_order + IMPORTING is_body TYPE ty_ps3order OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3order + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS get_order_by_id + IMPORTING iv_order_id TYPE int8 + RETURNING VALUE(rv_result) TYPE ty_ps3order + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS delete_order + IMPORTING iv_order_id TYPE int8 + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS create_user + IMPORTING is_body TYPE ty_ps3user OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3user + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS create_users_with_list_input + IMPORTING is_body TYPE ty_create_users_with_list_input_body_tab OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3user + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS login_user + IMPORTING + iv_username TYPE string OPTIONAL + iv_password TYPE string OPTIONAL + RETURNING VALUE(rv_result) TYPE string + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS logout_user + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS get_user_by_name + IMPORTING iv_username TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3user + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS update_user + IMPORTING + iv_username TYPE string + is_body TYPE ty_ps3user OPTIONAL + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS delete_user + IMPORTING iv_username TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + PROTECTED SECTION. + DATA mv_server TYPE string. + DATA mv_destination TYPE string. + DATA mv_api_key_api_key TYPE string. + METHODS on_authorize + IMPORTING io_request TYPE REF TO if_web_http_request. + PRIVATE SECTION. + METHODS _des_add_pet + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3pet. + METHODS _ser_add_pet + IMPORTING is_body TYPE ty_ps3pet + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_update_pet + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3pet. + METHODS _ser_update_pet + IMPORTING is_body TYPE ty_ps3pet + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_find_pets_by_status + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_find_pets_by_status_rv_result_tab. + METHODS _des_find_pets_by_tags + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_find_pets_by_tags_rv_result_tab. + METHODS _des_get_pet_by_id + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3pet. + METHODS _des_update_pet_with_form + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3pet. + METHODS _des_delete_pet + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _des_upload_file + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3api_response. + METHODS _ser_upload_file + IMPORTING is_body TYPE xstring + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_get_inventory + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE string. + METHODS _des_place_order + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3order. + METHODS _ser_place_order + IMPORTING is_body TYPE ty_ps3order + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_get_order_by_id + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3order. + METHODS _des_delete_order + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _des_create_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3user. + METHODS _ser_create_user + IMPORTING is_body TYPE ty_ps3user + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_create_users_with_list_input + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3user. + METHODS _ser_create_users_with_list_input + IMPORTING is_body TYPE ty_create_users_with_list_input_body_tab + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_login_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE string. + METHODS _des_logout_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _des_get_user_by_name + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3user. + METHODS _des_update_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _ser_update_user + IMPORTING is_body TYPE ty_ps3user + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_delete_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _build_client + IMPORTING iv_destination TYPE string + RETURNING VALUE(ro_client) TYPE REF TO if_web_http_client + RAISING cx_web_http_client_error + cx_http_dest_provider_error. + + METHODS _send_request + IMPORTING io_client TYPE REF TO if_web_http_client + io_request TYPE REF TO if_web_http_request + iv_method TYPE string + RETURNING VALUE(ro_response) TYPE REF TO if_web_http_response + RAISING cx_web_http_client_error + cx_web_message_error. + + METHODS _encode_path + IMPORTING iv_value TYPE string + RETURNING VALUE(rv_encoded) TYPE string. + + METHODS _serialize_query_param + IMPORTING iv_name TYPE string + iv_value TYPE string + iv_style TYPE string + iv_explode TYPE abap_bool + RETURNING VALUE(rv_qs) TYPE string. + + METHODS _join_url + IMPORTING iv_server TYPE string + iv_path TYPE string + it_query TYPE string_table + RETURNING VALUE(rv_url) TYPE string. + + TYPES: BEGIN OF ty_json_token, + kind TYPE string, + str_val TYPE string, + num_val TYPE decfloat34, + bool_val TYPE abap_bool, + END OF ty_json_token, + ty_json_tokens TYPE STANDARD TABLE OF ty_json_token WITH DEFAULT KEY. + + METHODS _json_tokenize + IMPORTING iv_json TYPE string + RETURNING VALUE(rt_tokens) TYPE ty_json_tokens + RAISING cx_sy_conversion_no_number. + + METHODS _json_escape + IMPORTING iv_value TYPE string + RETURNING VALUE(rv) TYPE string. + + METHODS _json_write_string + IMPORTING iv_value TYPE string + CHANGING ct_parts TYPE string_table. + + METHODS _json_write_number + IMPORTING iv_value TYPE decfloat34 + CHANGING ct_parts TYPE string_table. + + METHODS _json_write_bool + IMPORTING iv_value TYPE abap_bool + CHANGING ct_parts TYPE string_table. + + METHODS _json_write_null + CHANGING ct_parts TYPE string_table. + + METHODS _json_concat + IMPORTING it_parts TYPE string_table + RETURNING VALUE(rv) TYPE string. + + METHODS _json_hex_to_int + IMPORTING iv_hex TYPE string + RETURNING VALUE(rv) TYPE i. + + METHODS _json_codepoint_to_string + IMPORTING iv_code TYPE i + RETURNING VALUE(rv) TYPE string. + +ENDCLASS. + +CLASS ZCL_PETSTORE3_CLIENT IMPLEMENTATION. + METHOD constructor. + me->mv_server = iv_server. + me->mv_destination = iv_destination. + me->mv_api_key_api_key = iv_api_key_api_key. + ENDMETHOD. + + METHOD on_authorize. + RETURN. + ENDMETHOD. + + METHOD add_pet. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet|. + lv_body = me->_ser_add_pet( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_add_pet( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD update_pet. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet|. + lv_body = me->_ser_update_pet( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>put +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_update_pet( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD find_pets_by_status. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/findByStatus|. + APPEND me->_serialize_query_param( + iv_name = 'status' + iv_value = |{ iv_status }| + iv_style = 'form' + iv_explode = abap_true +) TO lt_query. + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_find_pets_by_status( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD find_pets_by_tags. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/findByTags|. + APPEND me->_serialize_query_param( + iv_name = 'tags' + iv_value = |{ iv_tags }| + iv_style = 'form' + iv_explode = abap_true +) TO lt_query. + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_find_pets_by_tags( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD get_pet_by_id. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/{ me->_encode_path( iv_value = iv_pet_id ) }|. + lo_req->set_header_field( i_name = 'api_key' i_value = mv_api_key_api_key ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_get_pet_by_id( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD update_pet_with_form. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/{ me->_encode_path( iv_value = iv_pet_id ) }|. + APPEND me->_serialize_query_param( + iv_name = 'name' + iv_value = |{ iv_name }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + APPEND me->_serialize_query_param( + iv_name = 'status' + iv_value = |{ iv_status }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_update_pet_with_form( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD delete_pet. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/{ me->_encode_path( iv_value = iv_pet_id ) }|. + lo_req->set_header_field( i_name = 'api_key' i_value = |{ iv_api_key }| ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>delete +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD upload_file. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/{ me->_encode_path( iv_value = iv_pet_id ) }/uploadImage|. + APPEND me->_serialize_query_param( + iv_name = 'additionalMetadata' + iv_value = |{ iv_additional_metadata }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + lo_req->set_binary( i_data = is_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/octet-stream' + ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_upload_file( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD get_inventory. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/store/inventory|. + lo_req->set_header_field( i_name = 'api_key' i_value = mv_api_key_api_key ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_get_inventory( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD place_order. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/store/order|. + lv_body = me->_ser_place_order( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_place_order( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD get_order_by_id. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/store/order/{ me->_encode_path( iv_value = iv_order_id ) }|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_get_order_by_id( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD delete_order. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/store/order/{ me->_encode_path( iv_value = iv_order_id ) }|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>delete +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD create_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user|. + lv_body = me->_ser_create_user( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_create_user( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD create_users_with_list_input. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/createWithList|. + lv_body = me->_ser_create_users_with_list_input( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_create_users_with_list_input( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD login_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/login|. + APPEND me->_serialize_query_param( + iv_name = 'username' + iv_value = |{ iv_username }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + APPEND me->_serialize_query_param( + iv_name = 'password' + iv_value = |{ iv_password }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_login_user( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD logout_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/logout|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD get_user_by_name. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/{ me->_encode_path( iv_value = iv_username ) }|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_get_user_by_name( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD update_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/{ me->_encode_path( iv_value = iv_username ) }|. + lv_body = me->_ser_update_user( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>put +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD delete_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/{ me->_encode_path( iv_value = iv_username ) }|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>delete +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD _des_add_pet. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_add_pet. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_update_pet. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_update_pet. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_find_pets_by_status. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_find_pets_by_tags. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_get_pet_by_id. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_update_pet_with_form. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_delete_pet. + rv_success = abap_true. + RETURN. + ENDMETHOD. + + METHOD _des_upload_file. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_upload_file. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_get_inventory. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_place_order. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_place_order. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_get_order_by_id. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_delete_order. + rv_success = abap_true. + RETURN. + ENDMETHOD. + + METHOD _des_create_user. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_create_user. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_create_users_with_list_input. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_create_users_with_list_input. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_login_user. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_logout_user. + rv_success = abap_true. + RETURN. + ENDMETHOD. + + METHOD _des_get_user_by_name. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_update_user. + rv_success = abap_true. + RETURN. + ENDMETHOD. + + METHOD _ser_update_user. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_delete_user. + rv_success = abap_true. + RETURN. + ENDMETHOD. + METHOD _build_client. + DATA(lo_destination) = cl_http_destination_provider=>create_by_comm_arrangement( + comm_scenario = iv_destination + comm_system_id = '' + service_id = '' ). + ro_client = cl_web_http_client_manager=>create_by_http_destination( lo_destination ). + ENDMETHOD. + + METHOD _send_request. + ro_response = io_client->execute( i_method = iv_method ). + ENDMETHOD. + + METHOD _encode_path. + rv_encoded = cl_http_utility=>escape_url( iv_value ). + ENDMETHOD. + + METHOD _serialize_query_param. + DATA(lv_name) = cl_http_utility=>escape_url( iv_name ). + DATA(lv_value) = cl_http_utility=>escape_url( iv_value ). + rv_qs = |{ lv_name }={ lv_value }|. + ENDMETHOD. + + METHOD _join_url. + rv_url = |{ iv_server }{ iv_path }|. + IF it_query IS NOT INITIAL. + DATA lv_first TYPE abap_bool VALUE abap_true. + rv_url = |{ rv_url }?|. + LOOP AT it_query INTO DATA(lv_q). + IF lv_first = abap_true. + rv_url = |{ rv_url }{ lv_q }|. + lv_first = abap_false. + ELSE. + rv_url = |{ rv_url }&{ lv_q }|. + ENDIF. + ENDLOOP. + ENDIF. + ENDMETHOD. + + METHOD _json_escape. + DATA(lv) = iv_value. + REPLACE ALL OCCURRENCES OF `\\` IN lv WITH `\\\\`. + REPLACE ALL OCCURRENCES OF `"` IN lv WITH `\\"`. + REPLACE ALL OCCURRENCES OF cl_abap_char_utilities=>newline IN lv WITH `\\n`. + REPLACE ALL OCCURRENCES OF cl_abap_char_utilities=>horizontal_tab IN lv WITH `\\t`. + rv = lv. + ENDMETHOD. + + METHOD _json_write_string. + APPEND |"{ _json_escape( iv_value ) }"| TO ct_parts. + ENDMETHOD. + + METHOD _json_write_number. + APPEND |{ iv_value }| TO ct_parts. + ENDMETHOD. + + METHOD _json_write_bool. + IF iv_value = abap_true. + APPEND `true` TO ct_parts. + ELSE. + APPEND `false` TO ct_parts. + ENDIF. + ENDMETHOD. + + METHOD _json_write_null. + APPEND `null` TO ct_parts. + ENDMETHOD. + + METHOD _json_concat. + rv = concat_lines_of( table = it_parts sep = `` ). + ENDMETHOD. + + METHOD _json_hex_to_int. + DATA: lv_i TYPE i VALUE 0, + lv_len TYPE i, + lv_c TYPE c LENGTH 1, + lv_v TYPE i. + rv = 0. + lv_len = strlen( iv_hex ). + WHILE lv_i < lv_len. + lv_c = iv_hex+lv_i(1). + IF lv_c CA `0123456789`. + lv_v = lv_c. + ELSEIF lv_c = `a` OR lv_c = `A`. + lv_v = 10. + ELSEIF lv_c = `b` OR lv_c = `B`. + lv_v = 11. + ELSEIF lv_c = `c` OR lv_c = `C`. + lv_v = 12. + ELSEIF lv_c = `d` OR lv_c = `D`. + lv_v = 13. + ELSEIF lv_c = `e` OR lv_c = `E`. + lv_v = 14. + ELSEIF lv_c = `f` OR lv_c = `F`. + lv_v = 15. + ENDIF. + rv = rv * 16 + lv_v. + lv_i = lv_i + 1. + ENDWHILE. + ENDMETHOD. + + METHOD _json_codepoint_to_string. + DATA: lv_x TYPE xstring, + lv_byte TYPE x LENGTH 1, + lv_cp TYPE i. + lv_cp = iv_code. + IF lv_cp < 128. + lv_byte = lv_cp. + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSEIF lv_cp < 2048. + lv_byte = 192 + ( lv_cp DIV 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSEIF lv_cp < 65536. + lv_byte = 224 + ( lv_cp DIV 4096 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 64 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSE. + lv_byte = 240 + ( lv_cp DIV 262144 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 4096 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 64 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ENDIF. + rv = cl_abap_conv_codepage=>create_in( codepage = `UTF-8` )->convert( source = lv_x ). + ENDMETHOD. + + METHOD _json_tokenize. + DATA: lv_len TYPE i, + lv_pos TYPE i VALUE 0, + lv_ch TYPE c LENGTH 1, + ls_token TYPE ty_json_token, + lv_buf TYPE string, + lv_esc TYPE c LENGTH 1, + lv_hex TYPE string, + lv_code TYPE i, + lv_code2 TYPE i, + lv_char TYPE string, + lv_num_start TYPE i, + lv_num_len TYPE i, + lv_num_str TYPE string, + lv_num_val TYPE decfloat34. + + lv_len = strlen( iv_json ). + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + + IF lv_ch = ` ` + OR lv_ch = cl_abap_char_utilities=>horizontal_tab + OR lv_ch = cl_abap_char_utilities=>newline + OR lv_ch = cl_abap_char_utilities=>cr_lf(1). + lv_pos = lv_pos + 1. + CONTINUE. + ENDIF. + + CLEAR ls_token. + CASE lv_ch. + WHEN `{`. + ls_token-kind = `object-start`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `}`. + ls_token-kind = `object-end`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `[`. + ls_token-kind = `array-start`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `]`. + ls_token-kind = `array-end`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `:`. + ls_token-kind = `colon`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `,`. + ls_token-kind = `comma`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + + WHEN `"`. + lv_pos = lv_pos + 1. + CLEAR lv_buf. + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + IF lv_ch = `"`. + lv_pos = lv_pos + 1. + EXIT. + ELSEIF lv_ch = `\\`. + lv_pos = lv_pos + 1. + lv_esc = iv_json+lv_pos(1). + CASE lv_esc. + WHEN `"`. + lv_buf = lv_buf && `"`. + WHEN `\\`. + lv_buf = lv_buf && `\\`. + WHEN `/`. + lv_buf = lv_buf && `/`. + WHEN `b`. + lv_buf = lv_buf && cl_abap_char_utilities=>backspace. + WHEN `f`. + lv_buf = lv_buf && cl_abap_char_utilities=>form_feed. + WHEN `n`. + lv_buf = lv_buf && cl_abap_char_utilities=>newline. + WHEN `r`. + lv_buf = lv_buf && cl_abap_char_utilities=>cr_lf(1). + WHEN `t`. + lv_buf = lv_buf && cl_abap_char_utilities=>horizontal_tab. + WHEN `u`. + lv_pos = lv_pos + 1. + lv_hex = iv_json+lv_pos(4). + lv_code = _json_hex_to_int( lv_hex ). + lv_pos = lv_pos + 3. + IF lv_code >= 55296 AND lv_code <= 56319. + lv_pos = lv_pos + 1. + IF iv_json+lv_pos(2) = `\\u`. + lv_pos = lv_pos + 2. + lv_hex = iv_json+lv_pos(4). + lv_code2 = _json_hex_to_int( lv_hex ). + lv_pos = lv_pos + 3. + lv_code = ( lv_code - 55296 ) * 1024 + ( lv_code2 - 56320 ) + 65536. + ELSE. + lv_pos = lv_pos - 1. + ENDIF. + ENDIF. + lv_char = _json_codepoint_to_string( lv_code ). + lv_buf = lv_buf && lv_char. + WHEN OTHERS. + lv_buf = lv_buf && lv_esc. + ENDCASE. + lv_pos = lv_pos + 1. + ELSE. + lv_buf = lv_buf && lv_ch. + lv_pos = lv_pos + 1. + ENDIF. + ENDWHILE. + ls_token-kind = `string`. + ls_token-str_val = lv_buf. + APPEND ls_token TO rt_tokens. + + WHEN `t`. + IF lv_pos + 4 <= lv_len AND iv_json+lv_pos(4) = `true`. + ls_token-kind = `bool`. + ls_token-bool_val = abap_true. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 4. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN `f`. + IF lv_pos + 5 <= lv_len AND iv_json+lv_pos(5) = `false`. + ls_token-kind = `bool`. + ls_token-bool_val = abap_false. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 5. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN `n`. + IF lv_pos + 4 <= lv_len AND iv_json+lv_pos(4) = `null`. + ls_token-kind = `null`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 4. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN OTHERS. + lv_num_start = lv_pos. + IF lv_ch = `-`. + lv_pos = lv_pos + 1. + ENDIF. + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + IF lv_ch CA `0123456789.eE+-`. + lv_pos = lv_pos + 1. + ELSE. + EXIT. + ENDIF. + ENDWHILE. + lv_num_len = lv_pos - lv_num_start. + lv_num_str = iv_json+lv_num_start(lv_num_len). + lv_num_val = lv_num_str. + ls_token-kind = `number`. + ls_token-num_val = lv_num_val. + APPEND ls_token TO rt_tokens. + ENDCASE. + ENDWHILE. + ENDMETHOD. + +ENDCLASS. \ No newline at end of file diff --git a/samples/petstore3-client/generated/abapgit/src/zcl_petstore3_client.clas.xml b/samples/petstore3-client/generated/abapgit/src/zcl_petstore3_client.clas.xml new file mode 100644 index 00000000..9045ed60 --- /dev/null +++ b/samples/petstore3-client/generated/abapgit/src/zcl_petstore3_client.clas.xml @@ -0,0 +1,16 @@ + + + + + + ZCL_PETSTORE3_CLIENT + E + Generated Petstore v3 client + 1 + X + X + X + + + + diff --git a/samples/petstore3-client/generated/abapgit/src/zcx_petstore3_client_error.clas.abap b/samples/petstore3-client/generated/abapgit/src/zcx_petstore3_client_error.clas.abap new file mode 100644 index 00000000..c6519cea --- /dev/null +++ b/samples/petstore3-client/generated/abapgit/src/zcx_petstore3_client_error.clas.abap @@ -0,0 +1,17 @@ +CLASS ZCX_PETSTORE3_CLIENT_ERROR DEFINITION PUBLIC CREATE PUBLIC FINAL INHERITING FROM cx_static_check. + PUBLIC SECTION. + DATA mv_status TYPE i READ-ONLY. + DATA mv_payload TYPE string READ-ONLY. + METHODS constructor + IMPORTING + iv_status TYPE i + iv_payload TYPE string. +ENDCLASS. + +CLASS ZCX_PETSTORE3_CLIENT_ERROR IMPLEMENTATION. + METHOD constructor. + super->constructor( ). + me->mv_status = iv_status. + me->mv_payload = iv_payload. + ENDMETHOD. +ENDCLASS. \ No newline at end of file diff --git a/samples/petstore3-client/generated/abapgit/src/zcx_petstore3_client_error.clas.xml b/samples/petstore3-client/generated/abapgit/src/zcx_petstore3_client_error.clas.xml new file mode 100644 index 00000000..2d461830 --- /dev/null +++ b/samples/petstore3-client/generated/abapgit/src/zcx_petstore3_client_error.clas.xml @@ -0,0 +1,16 @@ + + + + + + ZCX_PETSTORE3_CLIENT_ERROR + E + Generated error type for ZCL_PETSTORE3_CLIENT + 1 + X + X + X + + + + diff --git a/samples/petstore3-client/generated/gcts/CLAS/zcl_petstore3_client/zcl_petstore3_client.clas.abap b/samples/petstore3-client/generated/gcts/CLAS/zcl_petstore3_client/zcl_petstore3_client.clas.abap new file mode 100644 index 00000000..c714127a --- /dev/null +++ b/samples/petstore3-client/generated/gcts/CLAS/zcl_petstore3_client/zcl_petstore3_client.clas.abap @@ -0,0 +1,1444 @@ +CLASS ZCL_PETSTORE3_CLIENT DEFINITION PUBLIC CREATE PUBLIC. + PUBLIC SECTION. + TYPES: BEGIN OF ty_ps3order, + id TYPE int8, + pet_id TYPE int8, + quantity TYPE i, + ship_date TYPE timestampl, + status TYPE string, + complete TYPE abap_bool, + END OF ty_ps3order. + TYPES: BEGIN OF ty_ps3category, + id TYPE int8, + name TYPE string, + END OF ty_ps3category. + TYPES: BEGIN OF ty_ps3user, + id TYPE int8, + username TYPE string, + first_name TYPE string, + last_name TYPE string, + email TYPE string, + password TYPE string, + phone TYPE string, + user_status TYPE i, + END OF ty_ps3user. + TYPES: BEGIN OF ty_ps3tag, + id TYPE int8, + name TYPE string, + END OF ty_ps3tag. + TYPES ty_ps3pet__photo_urls_tab TYPE STANDARD TABLE OF string WITH DEFAULT KEY. + TYPES ty_ps3pet__tags_tab TYPE STANDARD TABLE OF ty_ps3tag WITH DEFAULT KEY. + TYPES: BEGIN OF ty_ps3pet, + id TYPE int8, + name TYPE string, + category TYPE ty_ps3category, + photo_urls TYPE ty_ps3pet__photo_urls_tab, + tags TYPE ty_ps3pet__tags_tab, + status TYPE string, + END OF ty_ps3pet. + TYPES: BEGIN OF ty_ps3api_response, + code TYPE i, + type TYPE string, + message TYPE string, + END OF ty_ps3api_response. + TYPES ty_find_pets_by_status_rv_result_tab TYPE STANDARD TABLE OF ty_ps3pet WITH DEFAULT KEY. + TYPES ty_find_pets_by_tags_iv_tags_tab TYPE STANDARD TABLE OF string WITH DEFAULT KEY. + TYPES ty_find_pets_by_tags_rv_result_tab TYPE STANDARD TABLE OF ty_ps3pet WITH DEFAULT KEY. + TYPES ty_create_users_with_list_input_body_tab TYPE STANDARD TABLE OF ty_ps3user WITH DEFAULT KEY. + CONSTANTS co_server_0 TYPE string VALUE '/api/v3'. + METHODS constructor + IMPORTING + iv_server TYPE string DEFAULT co_server_0 + iv_destination TYPE string OPTIONAL + iv_api_key_api_key TYPE string OPTIONAL. + METHODS add_pet + IMPORTING is_body TYPE ty_ps3pet + RETURNING VALUE(rv_result) TYPE ty_ps3pet + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS update_pet + IMPORTING is_body TYPE ty_ps3pet + RETURNING VALUE(rv_result) TYPE ty_ps3pet + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS find_pets_by_status + IMPORTING iv_status TYPE string + RETURNING VALUE(rv_result) TYPE ty_find_pets_by_status_rv_result_tab + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS find_pets_by_tags + IMPORTING iv_tags TYPE ty_find_pets_by_tags_iv_tags_tab + RETURNING VALUE(rv_result) TYPE ty_find_pets_by_tags_rv_result_tab + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS get_pet_by_id + IMPORTING iv_pet_id TYPE int8 + RETURNING VALUE(rv_result) TYPE ty_ps3pet + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS update_pet_with_form + IMPORTING + iv_pet_id TYPE int8 + iv_name TYPE string OPTIONAL + iv_status TYPE string OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3pet + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS delete_pet + IMPORTING + iv_api_key TYPE string OPTIONAL + iv_pet_id TYPE int8 + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS upload_file + IMPORTING + iv_pet_id TYPE int8 + iv_additional_metadata TYPE string OPTIONAL + is_body TYPE xstring OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3api_response + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS get_inventory + RETURNING VALUE(rv_result) TYPE string + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS place_order + IMPORTING is_body TYPE ty_ps3order OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3order + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS get_order_by_id + IMPORTING iv_order_id TYPE int8 + RETURNING VALUE(rv_result) TYPE ty_ps3order + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS delete_order + IMPORTING iv_order_id TYPE int8 + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS create_user + IMPORTING is_body TYPE ty_ps3user OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3user + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS create_users_with_list_input + IMPORTING is_body TYPE ty_create_users_with_list_input_body_tab OPTIONAL + RETURNING VALUE(rv_result) TYPE ty_ps3user + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS login_user + IMPORTING + iv_username TYPE string OPTIONAL + iv_password TYPE string OPTIONAL + RETURNING VALUE(rv_result) TYPE string + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS logout_user + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS get_user_by_name + IMPORTING iv_username TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3user + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS update_user + IMPORTING + iv_username TYPE string + is_body TYPE ty_ps3user OPTIONAL + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + METHODS delete_user + IMPORTING iv_username TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool + RAISING ZCX_PETSTORE3_CLIENT_ERROR. + PROTECTED SECTION. + DATA mv_server TYPE string. + DATA mv_destination TYPE string. + DATA mv_api_key_api_key TYPE string. + METHODS on_authorize + IMPORTING io_request TYPE REF TO if_web_http_request. + PRIVATE SECTION. + METHODS _des_add_pet + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3pet. + METHODS _ser_add_pet + IMPORTING is_body TYPE ty_ps3pet + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_update_pet + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3pet. + METHODS _ser_update_pet + IMPORTING is_body TYPE ty_ps3pet + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_find_pets_by_status + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_find_pets_by_status_rv_result_tab. + METHODS _des_find_pets_by_tags + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_find_pets_by_tags_rv_result_tab. + METHODS _des_get_pet_by_id + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3pet. + METHODS _des_update_pet_with_form + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3pet. + METHODS _des_delete_pet + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _des_upload_file + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3api_response. + METHODS _ser_upload_file + IMPORTING is_body TYPE xstring + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_get_inventory + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE string. + METHODS _des_place_order + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3order. + METHODS _ser_place_order + IMPORTING is_body TYPE ty_ps3order + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_get_order_by_id + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3order. + METHODS _des_delete_order + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _des_create_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3user. + METHODS _ser_create_user + IMPORTING is_body TYPE ty_ps3user + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_create_users_with_list_input + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3user. + METHODS _ser_create_users_with_list_input + IMPORTING is_body TYPE ty_create_users_with_list_input_body_tab + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_login_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE string. + METHODS _des_logout_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _des_get_user_by_name + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_result) TYPE ty_ps3user. + METHODS _des_update_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _ser_update_user + IMPORTING is_body TYPE ty_ps3user + RETURNING VALUE(rv_json) TYPE string. + METHODS _des_delete_user + IMPORTING iv_payload TYPE string + RETURNING VALUE(rv_success) TYPE abap_bool. + METHODS _build_client + IMPORTING iv_destination TYPE string + RETURNING VALUE(ro_client) TYPE REF TO if_web_http_client + RAISING cx_web_http_client_error + cx_http_dest_provider_error. + + METHODS _send_request + IMPORTING io_client TYPE REF TO if_web_http_client + io_request TYPE REF TO if_web_http_request + iv_method TYPE string + RETURNING VALUE(ro_response) TYPE REF TO if_web_http_response + RAISING cx_web_http_client_error + cx_web_message_error. + + METHODS _encode_path + IMPORTING iv_value TYPE string + RETURNING VALUE(rv_encoded) TYPE string. + + METHODS _serialize_query_param + IMPORTING iv_name TYPE string + iv_value TYPE string + iv_style TYPE string + iv_explode TYPE abap_bool + RETURNING VALUE(rv_qs) TYPE string. + + METHODS _join_url + IMPORTING iv_server TYPE string + iv_path TYPE string + it_query TYPE string_table + RETURNING VALUE(rv_url) TYPE string. + + TYPES: BEGIN OF ty_json_token, + kind TYPE string, + str_val TYPE string, + num_val TYPE decfloat34, + bool_val TYPE abap_bool, + END OF ty_json_token, + ty_json_tokens TYPE STANDARD TABLE OF ty_json_token WITH DEFAULT KEY. + + METHODS _json_tokenize + IMPORTING iv_json TYPE string + RETURNING VALUE(rt_tokens) TYPE ty_json_tokens + RAISING cx_sy_conversion_no_number. + + METHODS _json_escape + IMPORTING iv_value TYPE string + RETURNING VALUE(rv) TYPE string. + + METHODS _json_write_string + IMPORTING iv_value TYPE string + CHANGING ct_parts TYPE string_table. + + METHODS _json_write_number + IMPORTING iv_value TYPE decfloat34 + CHANGING ct_parts TYPE string_table. + + METHODS _json_write_bool + IMPORTING iv_value TYPE abap_bool + CHANGING ct_parts TYPE string_table. + + METHODS _json_write_null + CHANGING ct_parts TYPE string_table. + + METHODS _json_concat + IMPORTING it_parts TYPE string_table + RETURNING VALUE(rv) TYPE string. + + METHODS _json_hex_to_int + IMPORTING iv_hex TYPE string + RETURNING VALUE(rv) TYPE i. + + METHODS _json_codepoint_to_string + IMPORTING iv_code TYPE i + RETURNING VALUE(rv) TYPE string. + +ENDCLASS. + +CLASS ZCL_PETSTORE3_CLIENT IMPLEMENTATION. + METHOD constructor. + me->mv_server = iv_server. + me->mv_destination = iv_destination. + me->mv_api_key_api_key = iv_api_key_api_key. + ENDMETHOD. + + METHOD on_authorize. + RETURN. + ENDMETHOD. + + METHOD add_pet. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet|. + lv_body = me->_ser_add_pet( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_add_pet( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD update_pet. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet|. + lv_body = me->_ser_update_pet( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>put +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_update_pet( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD find_pets_by_status. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/findByStatus|. + APPEND me->_serialize_query_param( + iv_name = 'status' + iv_value = |{ iv_status }| + iv_style = 'form' + iv_explode = abap_true +) TO lt_query. + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_find_pets_by_status( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD find_pets_by_tags. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/findByTags|. + APPEND me->_serialize_query_param( + iv_name = 'tags' + iv_value = |{ iv_tags }| + iv_style = 'form' + iv_explode = abap_true +) TO lt_query. + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_find_pets_by_tags( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD get_pet_by_id. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/{ me->_encode_path( iv_value = iv_pet_id ) }|. + lo_req->set_header_field( i_name = 'api_key' i_value = mv_api_key_api_key ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_get_pet_by_id( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD update_pet_with_form. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/{ me->_encode_path( iv_value = iv_pet_id ) }|. + APPEND me->_serialize_query_param( + iv_name = 'name' + iv_value = |{ iv_name }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + APPEND me->_serialize_query_param( + iv_name = 'status' + iv_value = |{ iv_status }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_update_pet_with_form( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD delete_pet. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/{ me->_encode_path( iv_value = iv_pet_id ) }|. + lo_req->set_header_field( i_name = 'api_key' i_value = |{ iv_api_key }| ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>delete +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD upload_file. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/pet/{ me->_encode_path( iv_value = iv_pet_id ) }/uploadImage|. + APPEND me->_serialize_query_param( + iv_name = 'additionalMetadata' + iv_value = |{ iv_additional_metadata }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + lo_req->set_binary( i_data = is_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/octet-stream' + ). + me->on_authorize( io_request = lo_req ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_upload_file( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD get_inventory. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/store/inventory|. + lo_req->set_header_field( i_name = 'api_key' i_value = mv_api_key_api_key ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_get_inventory( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD place_order. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/store/order|. + lv_body = me->_ser_place_order( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_place_order( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD get_order_by_id. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/store/order/{ me->_encode_path( iv_value = iv_order_id ) }|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_get_order_by_id( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD delete_order. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/store/order/{ me->_encode_path( iv_value = iv_order_id ) }|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>delete +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD create_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user|. + lv_body = me->_ser_create_user( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_create_user( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD create_users_with_list_input. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/createWithList|. + lv_body = me->_ser_create_users_with_list_input( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>post +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_create_users_with_list_input( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD login_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/login|. + APPEND me->_serialize_query_param( + iv_name = 'username' + iv_value = |{ iv_username }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + APPEND me->_serialize_query_param( + iv_name = 'password' + iv_value = |{ iv_password }| + iv_style = 'form' + iv_explode = abap_false +) TO lt_query. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_login_user( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD logout_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/logout|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD get_user_by_name. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/{ me->_encode_path( iv_value = iv_username ) }|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>get +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_result = me->_des_get_user_by_name( iv_payload = lv_payload ). + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD update_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/{ me->_encode_path( iv_value = iv_username ) }|. + lv_body = me->_ser_update_user( is_body = is_body ). + lo_req->set_text( i_text = lv_body ). + lo_req->set_header_field( + i_name = 'content-type' + i_value = 'application/json' + ). + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>put +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD delete_user. + DATA lo_client TYPE REF TO if_web_http_client. + DATA lo_req TYPE REF TO if_web_http_request. + DATA lo_resp TYPE REF TO if_web_http_response. + DATA lv_url TYPE string. + DATA lv_path TYPE string. + DATA lv_body TYPE string. + DATA lv_status TYPE i. + DATA lv_payload TYPE string. + DATA lt_query TYPE string_table. + lo_client = me->_build_client( iv_destination = mv_destination ). + lo_req = lo_client->get_http_request( ). + lv_path = |/user/{ me->_encode_path( iv_value = iv_username ) }|. + lv_url = me->_join_url( iv_server = mv_server iv_path = lv_path it_query = lt_query ). + lo_req->set_uri( i_uri = lv_url ). + lo_resp = me->_send_request( + io_client = lo_client + io_request = lo_req + iv_method = if_web_http_client=>delete +). + lv_status = lo_resp->get_status( )-code. + lv_payload = lo_resp->get_text( ). + IF lv_status >= 200 AND lv_status < 300. + rv_success = abap_true. + RETURN. + ENDIF. + RAISE EXCEPTION NEW ZCX_PETSTORE3_CLIENT_ERROR( + iv_status = lv_status + iv_payload = lv_payload + ). + ENDMETHOD. + + METHOD _des_add_pet. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_add_pet. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_update_pet. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_update_pet. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_find_pets_by_status. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_find_pets_by_tags. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_get_pet_by_id. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_update_pet_with_form. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_delete_pet. + rv_success = abap_true. + RETURN. + ENDMETHOD. + + METHOD _des_upload_file. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_upload_file. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_get_inventory. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_place_order. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_place_order. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_get_order_by_id. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_delete_order. + rv_success = abap_true. + RETURN. + ENDMETHOD. + + METHOD _des_create_user. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_create_user. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_create_users_with_list_input. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _ser_create_users_with_list_input. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_login_user. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_logout_user. + rv_success = abap_true. + RETURN. + ENDMETHOD. + + METHOD _des_get_user_by_name. + CLEAR rv_result. + RETURN. + ENDMETHOD. + + METHOD _des_update_user. + rv_success = abap_true. + RETURN. + ENDMETHOD. + + METHOD _ser_update_user. + CLEAR rv_json. + RETURN. + ENDMETHOD. + + METHOD _des_delete_user. + rv_success = abap_true. + RETURN. + ENDMETHOD. + METHOD _build_client. + DATA(lo_destination) = cl_http_destination_provider=>create_by_comm_arrangement( + comm_scenario = iv_destination + comm_system_id = '' + service_id = '' ). + ro_client = cl_web_http_client_manager=>create_by_http_destination( lo_destination ). + ENDMETHOD. + + METHOD _send_request. + ro_response = io_client->execute( i_method = iv_method ). + ENDMETHOD. + + METHOD _encode_path. + rv_encoded = cl_http_utility=>escape_url( iv_value ). + ENDMETHOD. + + METHOD _serialize_query_param. + DATA(lv_name) = cl_http_utility=>escape_url( iv_name ). + DATA(lv_value) = cl_http_utility=>escape_url( iv_value ). + rv_qs = |{ lv_name }={ lv_value }|. + ENDMETHOD. + + METHOD _join_url. + rv_url = |{ iv_server }{ iv_path }|. + IF it_query IS NOT INITIAL. + DATA lv_first TYPE abap_bool VALUE abap_true. + rv_url = |{ rv_url }?|. + LOOP AT it_query INTO DATA(lv_q). + IF lv_first = abap_true. + rv_url = |{ rv_url }{ lv_q }|. + lv_first = abap_false. + ELSE. + rv_url = |{ rv_url }&{ lv_q }|. + ENDIF. + ENDLOOP. + ENDIF. + ENDMETHOD. + + METHOD _json_escape. + DATA(lv) = iv_value. + REPLACE ALL OCCURRENCES OF `\\` IN lv WITH `\\\\`. + REPLACE ALL OCCURRENCES OF `"` IN lv WITH `\\"`. + REPLACE ALL OCCURRENCES OF cl_abap_char_utilities=>newline IN lv WITH `\\n`. + REPLACE ALL OCCURRENCES OF cl_abap_char_utilities=>horizontal_tab IN lv WITH `\\t`. + rv = lv. + ENDMETHOD. + + METHOD _json_write_string. + APPEND |"{ _json_escape( iv_value ) }"| TO ct_parts. + ENDMETHOD. + + METHOD _json_write_number. + APPEND |{ iv_value }| TO ct_parts. + ENDMETHOD. + + METHOD _json_write_bool. + IF iv_value = abap_true. + APPEND `true` TO ct_parts. + ELSE. + APPEND `false` TO ct_parts. + ENDIF. + ENDMETHOD. + + METHOD _json_write_null. + APPEND `null` TO ct_parts. + ENDMETHOD. + + METHOD _json_concat. + rv = concat_lines_of( table = it_parts sep = `` ). + ENDMETHOD. + + METHOD _json_hex_to_int. + DATA: lv_i TYPE i VALUE 0, + lv_len TYPE i, + lv_c TYPE c LENGTH 1, + lv_v TYPE i. + rv = 0. + lv_len = strlen( iv_hex ). + WHILE lv_i < lv_len. + lv_c = iv_hex+lv_i(1). + IF lv_c CA `0123456789`. + lv_v = lv_c. + ELSEIF lv_c = `a` OR lv_c = `A`. + lv_v = 10. + ELSEIF lv_c = `b` OR lv_c = `B`. + lv_v = 11. + ELSEIF lv_c = `c` OR lv_c = `C`. + lv_v = 12. + ELSEIF lv_c = `d` OR lv_c = `D`. + lv_v = 13. + ELSEIF lv_c = `e` OR lv_c = `E`. + lv_v = 14. + ELSEIF lv_c = `f` OR lv_c = `F`. + lv_v = 15. + ENDIF. + rv = rv * 16 + lv_v. + lv_i = lv_i + 1. + ENDWHILE. + ENDMETHOD. + + METHOD _json_codepoint_to_string. + DATA: lv_x TYPE xstring, + lv_byte TYPE x LENGTH 1, + lv_cp TYPE i. + lv_cp = iv_code. + IF lv_cp < 128. + lv_byte = lv_cp. + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSEIF lv_cp < 2048. + lv_byte = 192 + ( lv_cp DIV 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSEIF lv_cp < 65536. + lv_byte = 224 + ( lv_cp DIV 4096 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 64 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ELSE. + lv_byte = 240 + ( lv_cp DIV 262144 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 4096 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( ( lv_cp DIV 64 ) MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + lv_byte = 128 + ( lv_cp MOD 64 ). + CONCATENATE lv_x lv_byte INTO lv_x IN BYTE MODE. + ENDIF. + rv = cl_abap_conv_codepage=>create_in( codepage = `UTF-8` )->convert( source = lv_x ). + ENDMETHOD. + + METHOD _json_tokenize. + DATA: lv_len TYPE i, + lv_pos TYPE i VALUE 0, + lv_ch TYPE c LENGTH 1, + ls_token TYPE ty_json_token, + lv_buf TYPE string, + lv_esc TYPE c LENGTH 1, + lv_hex TYPE string, + lv_code TYPE i, + lv_code2 TYPE i, + lv_char TYPE string, + lv_num_start TYPE i, + lv_num_len TYPE i, + lv_num_str TYPE string, + lv_num_val TYPE decfloat34. + + lv_len = strlen( iv_json ). + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + + IF lv_ch = ` ` + OR lv_ch = cl_abap_char_utilities=>horizontal_tab + OR lv_ch = cl_abap_char_utilities=>newline + OR lv_ch = cl_abap_char_utilities=>cr_lf(1). + lv_pos = lv_pos + 1. + CONTINUE. + ENDIF. + + CLEAR ls_token. + CASE lv_ch. + WHEN `{`. + ls_token-kind = `object-start`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `}`. + ls_token-kind = `object-end`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `[`. + ls_token-kind = `array-start`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `]`. + ls_token-kind = `array-end`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `:`. + ls_token-kind = `colon`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + WHEN `,`. + ls_token-kind = `comma`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 1. + + WHEN `"`. + lv_pos = lv_pos + 1. + CLEAR lv_buf. + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + IF lv_ch = `"`. + lv_pos = lv_pos + 1. + EXIT. + ELSEIF lv_ch = `\\`. + lv_pos = lv_pos + 1. + lv_esc = iv_json+lv_pos(1). + CASE lv_esc. + WHEN `"`. + lv_buf = lv_buf && `"`. + WHEN `\\`. + lv_buf = lv_buf && `\\`. + WHEN `/`. + lv_buf = lv_buf && `/`. + WHEN `b`. + lv_buf = lv_buf && cl_abap_char_utilities=>backspace. + WHEN `f`. + lv_buf = lv_buf && cl_abap_char_utilities=>form_feed. + WHEN `n`. + lv_buf = lv_buf && cl_abap_char_utilities=>newline. + WHEN `r`. + lv_buf = lv_buf && cl_abap_char_utilities=>cr_lf(1). + WHEN `t`. + lv_buf = lv_buf && cl_abap_char_utilities=>horizontal_tab. + WHEN `u`. + lv_pos = lv_pos + 1. + lv_hex = iv_json+lv_pos(4). + lv_code = _json_hex_to_int( lv_hex ). + lv_pos = lv_pos + 3. + IF lv_code >= 55296 AND lv_code <= 56319. + lv_pos = lv_pos + 1. + IF iv_json+lv_pos(2) = `\\u`. + lv_pos = lv_pos + 2. + lv_hex = iv_json+lv_pos(4). + lv_code2 = _json_hex_to_int( lv_hex ). + lv_pos = lv_pos + 3. + lv_code = ( lv_code - 55296 ) * 1024 + ( lv_code2 - 56320 ) + 65536. + ELSE. + lv_pos = lv_pos - 1. + ENDIF. + ENDIF. + lv_char = _json_codepoint_to_string( lv_code ). + lv_buf = lv_buf && lv_char. + WHEN OTHERS. + lv_buf = lv_buf && lv_esc. + ENDCASE. + lv_pos = lv_pos + 1. + ELSE. + lv_buf = lv_buf && lv_ch. + lv_pos = lv_pos + 1. + ENDIF. + ENDWHILE. + ls_token-kind = `string`. + ls_token-str_val = lv_buf. + APPEND ls_token TO rt_tokens. + + WHEN `t`. + IF lv_pos + 4 <= lv_len AND iv_json+lv_pos(4) = `true`. + ls_token-kind = `bool`. + ls_token-bool_val = abap_true. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 4. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN `f`. + IF lv_pos + 5 <= lv_len AND iv_json+lv_pos(5) = `false`. + ls_token-kind = `bool`. + ls_token-bool_val = abap_false. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 5. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN `n`. + IF lv_pos + 4 <= lv_len AND iv_json+lv_pos(4) = `null`. + ls_token-kind = `null`. + APPEND ls_token TO rt_tokens. + lv_pos = lv_pos + 4. + ELSE. + lv_pos = lv_pos + 1. + ENDIF. + + WHEN OTHERS. + lv_num_start = lv_pos. + IF lv_ch = `-`. + lv_pos = lv_pos + 1. + ENDIF. + WHILE lv_pos < lv_len. + lv_ch = iv_json+lv_pos(1). + IF lv_ch CA `0123456789.eE+-`. + lv_pos = lv_pos + 1. + ELSE. + EXIT. + ENDIF. + ENDWHILE. + lv_num_len = lv_pos - lv_num_start. + lv_num_str = iv_json+lv_num_start(lv_num_len). + lv_num_val = lv_num_str. + ls_token-kind = `number`. + ls_token-num_val = lv_num_val. + APPEND ls_token TO rt_tokens. + ENDCASE. + ENDWHILE. + ENDMETHOD. + +ENDCLASS. \ No newline at end of file diff --git a/samples/petstore3-client/generated/gcts/CLAS/zcl_petstore3_client/zcl_petstore3_client.clas.json b/samples/petstore3-client/generated/gcts/CLAS/zcl_petstore3_client/zcl_petstore3_client.clas.json new file mode 100644 index 00000000..5a1cbf6b --- /dev/null +++ b/samples/petstore3-client/generated/gcts/CLAS/zcl_petstore3_client/zcl_petstore3_client.clas.json @@ -0,0 +1,16 @@ +{ + "header": { + "formatVersion": "1.0", + "description": "Generated Petstore v3 client", + "originalLanguage": "E" + }, + "class": { + "name": "ZCL_PETSTORE3_CLIENT", + "category": "00", + "visibility": "public", + "final": true, + "abstract": false, + "fixedPointArithmetic": true, + "unicodeChecksActive": true + } +} diff --git a/samples/petstore3-client/generated/gcts/CLAS/zcx_petstore3_client_error/zcx_petstore3_client_error.clas.abap b/samples/petstore3-client/generated/gcts/CLAS/zcx_petstore3_client_error/zcx_petstore3_client_error.clas.abap new file mode 100644 index 00000000..c6519cea --- /dev/null +++ b/samples/petstore3-client/generated/gcts/CLAS/zcx_petstore3_client_error/zcx_petstore3_client_error.clas.abap @@ -0,0 +1,17 @@ +CLASS ZCX_PETSTORE3_CLIENT_ERROR DEFINITION PUBLIC CREATE PUBLIC FINAL INHERITING FROM cx_static_check. + PUBLIC SECTION. + DATA mv_status TYPE i READ-ONLY. + DATA mv_payload TYPE string READ-ONLY. + METHODS constructor + IMPORTING + iv_status TYPE i + iv_payload TYPE string. +ENDCLASS. + +CLASS ZCX_PETSTORE3_CLIENT_ERROR IMPLEMENTATION. + METHOD constructor. + super->constructor( ). + me->mv_status = iv_status. + me->mv_payload = iv_payload. + ENDMETHOD. +ENDCLASS. \ No newline at end of file diff --git a/samples/petstore3-client/generated/gcts/CLAS/zcx_petstore3_client_error/zcx_petstore3_client_error.clas.json b/samples/petstore3-client/generated/gcts/CLAS/zcx_petstore3_client_error/zcx_petstore3_client_error.clas.json new file mode 100644 index 00000000..aea6e389 --- /dev/null +++ b/samples/petstore3-client/generated/gcts/CLAS/zcx_petstore3_client_error/zcx_petstore3_client_error.clas.json @@ -0,0 +1,16 @@ +{ + "header": { + "formatVersion": "1.0", + "description": "Generated error type for ZCL_PETSTORE3_CLIENT", + "originalLanguage": "E" + }, + "class": { + "name": "ZCX_PETSTORE3_CLIENT_ERROR", + "category": "00", + "visibility": "public", + "final": true, + "abstract": false, + "fixedPointArithmetic": true, + "unicodeChecksActive": true + } +} diff --git a/samples/petstore3-client/generated/gcts/package.devc.json b/samples/petstore3-client/generated/gcts/package.devc.json new file mode 100644 index 00000000..7a356eb8 --- /dev/null +++ b/samples/petstore3-client/generated/gcts/package.devc.json @@ -0,0 +1,9 @@ +{ + "header": { + "formatVersion": "1.0", + "description": "Generated by @abapify/openai-codegen" + }, + "package": { + "softwareComponent": "LOCAL" + } +} diff --git a/samples/petstore3-client/package.json b/samples/petstore3-client/package.json new file mode 100644 index 00000000..7b3e4978 --- /dev/null +++ b/samples/petstore3-client/package.json @@ -0,0 +1,15 @@ +{ + "name": "petstore3-client-sample", + "private": true, + "version": "0.0.0", + "description": "Generated ABAP client for the Swagger Petstore v3 OpenAPI spec. Used as a fixture for @abapify/openai-codegen and as an end-to-end target deployed to a real SAP BTP (Steampunk) system via adt-cli.", + "type": "module", + "scripts": { + "generate:abapgit": "openai-codegen --input ./spec/openapi.json --out ./generated/abapgit --target s4-cloud --format abapgit --class-name ZCL_PETSTORE3_CLIENT --type-prefix ZPS3_", + "generate:gcts": "openai-codegen --input ./spec/openapi.json --out ./generated/gcts --target s4-cloud --format gcts --class-name ZCL_PETSTORE3_CLIENT --type-prefix ZPS3_", + "generate": "bun run generate:abapgit && bun run generate:gcts" + }, + "dependencies": { + "@abapify/openai-codegen": "workspace:*" + } +} diff --git a/samples/petstore3-client/spec/openapi.json b/samples/petstore3-client/spec/openapi.json new file mode 100644 index 00000000..147f85d6 --- /dev/null +++ b/samples/petstore3-client/spec/openapi.json @@ -0,0 +1,814 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "Swagger Petstore - OpenAPI 3.0", + "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + "termsOfService": "https://swagger.io/terms/", + "contact": { "email": "apiteam@swagger.io" }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.27" + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "https://swagger.io" + }, + "servers": [{ "url": "/api/v3" }], + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "https://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "description": "Find out more about our store", + "url": "https://swagger.io" + } + }, + { "name": "user", "description": "Operations about user" } + ], + "paths": { + "/pet": { + "put": { + "tags": ["pet"], + "summary": "Update an existing pet.", + "description": "Update an existing pet by Id.", + "operationId": "updatePet", + "requestBody": { + "description": "Update an existent pet in the store", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/x-www-form-urlencoded": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "400": { "description": "Invalid ID supplied" }, + "404": { "description": "Pet not found" }, + "422": { "description": "Validation exception" }, + "default": { "description": "Unexpected error" } + }, + "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] + }, + "post": { + "tags": ["pet"], + "summary": "Add a new pet to the store.", + "description": "Add a new pet to the store.", + "operationId": "addPet", + "requestBody": { + "description": "Create a new pet in the store", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/x-www-form-urlencoded": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "400": { "description": "Invalid input" }, + "422": { "description": "Validation exception" }, + "default": { "description": "Unexpected error" } + }, + "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] + } + }, + "/pet/findByStatus": { + "get": { + "tags": ["pet"], + "summary": "Finds Pets by status.", + "description": "Multiple status values can be provided with comma separated strings.", + "operationId": "findPetsByStatus", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": true, + "explode": true, + "schema": { + "type": "string", + "default": "available", + "enum": ["available", "pending", "sold"] + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + } + }, + "application/xml": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + } + } + } + }, + "400": { "description": "Invalid status value" }, + "default": { "description": "Unexpected error" } + }, + "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] + } + }, + "/pet/findByTags": { + "get": { + "tags": ["pet"], + "summary": "Finds Pets by tags.", + "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": true, + "explode": true, + "schema": { "type": "array", "items": { "type": "string" } } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + } + }, + "application/xml": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + } + } + } + }, + "400": { "description": "Invalid tag value" }, + "default": { "description": "Unexpected error" } + }, + "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] + } + }, + "/pet/{petId}": { + "get": { + "tags": ["pet"], + "summary": "Find pet by ID.", + "description": "Returns a single pet.", + "operationId": "getPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "schema": { "type": "integer", "format": "int64" } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "400": { "description": "Invalid ID supplied" }, + "404": { "description": "Pet not found" }, + "default": { "description": "Unexpected error" } + }, + "security": [ + { "api_key": [] }, + { "petstore_auth": ["write:pets", "read:pets"] } + ] + }, + "post": { + "tags": ["pet"], + "summary": "Updates a pet in the store with form data.", + "description": "Updates a pet resource based on the form data.", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { "type": "integer", "format": "int64" } + }, + { + "name": "name", + "in": "query", + "description": "Name of pet that needs to be updated", + "schema": { "type": "string" } + }, + { + "name": "status", + "in": "query", + "description": "Status of pet that needs to be updated", + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "400": { "description": "Invalid input" }, + "default": { "description": "Unexpected error" } + }, + "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] + }, + "delete": { + "tags": ["pet"], + "summary": "Deletes a pet.", + "description": "Delete a pet.", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "description": "", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { "type": "integer", "format": "int64" } + } + ], + "responses": { + "200": { "description": "Pet deleted" }, + "400": { "description": "Invalid pet value" }, + "default": { "description": "Unexpected error" } + }, + "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": ["pet"], + "summary": "Uploads an image.", + "description": "Upload image of the pet.", + "operationId": "uploadFile", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "schema": { "type": "integer", "format": "int64" } + }, + { + "name": "additionalMetadata", + "in": "query", + "description": "Additional Metadata", + "required": false, + "schema": { "type": "string" } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponse" } + } + } + }, + "400": { "description": "No file uploaded" }, + "404": { "description": "Pet not found" }, + "default": { "description": "Unexpected error" } + }, + "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] + } + }, + "/store/inventory": { + "get": { + "tags": ["store"], + "summary": "Returns pet inventories by status.", + "description": "Returns a map of status codes to quantities.", + "operationId": "getInventory", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "default": { "description": "Unexpected error" } + }, + "security": [{ "api_key": [] }] + } + }, + "/store/order": { + "post": { + "tags": ["store"], + "summary": "Place an order for a pet.", + "description": "Place a new order in the store.", + "operationId": "placeOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Order" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Order" } + }, + "application/x-www-form-urlencoded": { + "schema": { "$ref": "#/components/schemas/Order" } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Order" } + } + } + }, + "400": { "description": "Invalid input" }, + "422": { "description": "Validation exception" }, + "default": { "description": "Unexpected error" } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": ["store"], + "summary": "Find purchase order by ID.", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + "operationId": "getOrderById", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of order that needs to be fetched", + "required": true, + "schema": { "type": "integer", "format": "int64" } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Order" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Order" } + } + } + }, + "400": { "description": "Invalid ID supplied" }, + "404": { "description": "Order not found" }, + "default": { "description": "Unexpected error" } + } + }, + "delete": { + "tags": ["store"], + "summary": "Delete purchase order by identifier.", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "schema": { "type": "integer", "format": "int64" } + } + ], + "responses": { + "200": { "description": "order deleted" }, + "400": { "description": "Invalid ID supplied" }, + "404": { "description": "Order not found" }, + "default": { "description": "Unexpected error" } + } + } + }, + "/user": { + "post": { + "tags": ["user"], + "summary": "Create user.", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "requestBody": { + "description": "Created user object", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/User" } + }, + "application/x-www-form-urlencoded": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "default": { "description": "Unexpected error" } + } + } + }, + "/user/createWithList": { + "post": { + "tags": ["user"], + "summary": "Creates list of users with given input array.", + "description": "Creates list of users with given input array.", + "operationId": "createUsersWithListInput", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/User" } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "default": { "description": "Unexpected error" } + } + } + }, + "/user/login": { + "get": { + "tags": ["user"], + "summary": "Logs user into the system.", + "description": "Log into the system.", + "operationId": "loginUser", + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": false, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "description": "calls per hour allowed by the user", + "schema": { "type": "integer", "format": "int32" } + }, + "X-Expires-After": { + "description": "date in UTC when token expires", + "schema": { "type": "string", "format": "date-time" } + } + }, + "content": { + "application/xml": { "schema": { "type": "string" } }, + "application/json": { "schema": { "type": "string" } } + } + }, + "400": { "description": "Invalid username/password supplied" }, + "default": { "description": "Unexpected error" } + } + } + }, + "/user/logout": { + "get": { + "tags": ["user"], + "summary": "Logs out current logged in user session.", + "description": "Log user out of the system.", + "operationId": "logoutUser", + "parameters": [], + "responses": { + "200": { "description": "successful operation" }, + "default": { "description": "Unexpected error" } + } + } + }, + "/user/{username}": { + "get": { + "tags": ["user"], + "summary": "Get user by user name.", + "description": "Get user detail based on username.", + "operationId": "getUserByName", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "400": { "description": "Invalid username supplied" }, + "404": { "description": "User not found" }, + "default": { "description": "Unexpected error" } + } + }, + "put": { + "tags": ["user"], + "summary": "Update user resource.", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "description": "Update an existent user in the store", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/User" } + }, + "application/x-www-form-urlencoded": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "responses": { + "200": { "description": "successful operation" }, + "400": { "description": "bad request" }, + "404": { "description": "user not found" }, + "default": { "description": "Unexpected error" } + } + }, + "delete": { + "tags": ["user"], + "summary": "Delete user resource.", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { "description": "User deleted" }, + "400": { "description": "Invalid username supplied" }, + "404": { "description": "User not found" }, + "default": { "description": "Unexpected error" } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64", "example": 10 }, + "petId": { "type": "integer", "format": "int64", "example": 198772 }, + "quantity": { "type": "integer", "format": "int32", "example": 7 }, + "shipDate": { "type": "string", "format": "date-time" }, + "status": { + "type": "string", + "description": "Order Status", + "example": "approved", + "enum": ["placed", "approved", "delivered"] + }, + "complete": { "type": "boolean" } + }, + "xml": { "name": "order" } + }, + "Category": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64", "example": 1 }, + "name": { "type": "string", "example": "Dogs" } + }, + "xml": { "name": "category" } + }, + "User": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64", "example": 10 }, + "username": { "type": "string", "example": "theUser" }, + "firstName": { "type": "string", "example": "John" }, + "lastName": { "type": "string", "example": "James" }, + "email": { "type": "string", "example": "john@email.com" }, + "password": { "type": "string", "example": "12345" }, + "phone": { "type": "string", "example": "12345" }, + "userStatus": { + "type": "integer", + "description": "User Status", + "format": "int32", + "example": 1 + } + }, + "xml": { "name": "user" } + }, + "Tag": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" } + }, + "xml": { "name": "tag" } + }, + "Pet": { + "required": ["name", "photoUrls"], + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64", "example": 10 }, + "name": { "type": "string", "example": "doggie" }, + "category": { "$ref": "#/components/schemas/Category" }, + "photoUrls": { + "type": "array", + "xml": { "wrapped": true }, + "items": { "type": "string", "xml": { "name": "photoUrl" } } + }, + "tags": { + "type": "array", + "xml": { "wrapped": true }, + "items": { "$ref": "#/components/schemas/Tag" } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": ["available", "pending", "sold"] + } + }, + "xml": { "name": "pet" } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "type": { "type": "string" }, + "message": { "type": "string" } + }, + "xml": { "name": "##default" } + } + }, + "requestBodies": { + "Pet": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + }, + "application/xml": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "UserArray": { + "description": "List of user object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/User" } + } + } + } + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { "type": "apiKey", "name": "api_key", "in": "header" } + } + } +} diff --git a/tsconfig.json b/tsconfig.json index bbd9d799..bdd099af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -101,6 +101,12 @@ }, { "path": "./packages/adt-rfc" + }, + { + "path": "./packages/openai-codegen" + }, + { + "path": "./packages/abap-ast" } ] }