ABAP REST API Gateway Without SAP API Management
ABAP

ABAP REST API Gateway Without SAP API Management

If you've ever needed to expose multiple ABAP backends through a single, consistent HTTP interface — but didn't have SAP API Management licensed or deployed — you've probably wondered whether you can build a lightweight ABAP REST API gateway yourself. The answer is yes, and it's more practical than most people think.

This isn't about replacing a full API management platform. It's about solving a real problem: you have a handful of internal consumers, a mix of RFC-based logic, BAPIs, and custom ABAP classes, and you want one clean entry point that handles routing, authentication, and basic error normalization. Let's build that.

What Does an ABAP REST API Gateway Actually Do?

Before writing a line of code, let's define scope. A gateway in this context does three things:

  • Routes incoming HTTP requests to the correct handler based on path and method
  • Authenticates the caller before dispatching anything
  • Normalizes responses into a consistent JSON envelope regardless of where the data comes from

You are not building rate limiting, OAuth token introspection, or a developer portal. Those belong in a proper API management layer. You're building the ABAP-side routing kernel that sits behind your ICF (Internet Communication Framework) endpoint.

Setting Up the ICF Handler

Everything starts with an ICF node. In transaction SICF, create a new service node under /sap/bc/ — something like /sap/bc/zgw/api. Assign a handler class to it. This class is your gateway's front door.

The handler class must implement IF_HTTP_EXTENSION:

CLASS zcl_api_gateway DEFINITION PUBLIC FINAL CREATE PUBLIC.
  PUBLIC SECTION.
    INTERFACES if_http_extension.
ENDCLASS.

CLASS zcl_api_gateway IMPLEMENTATION.
  METHOD if_http_extension~handle_request.
    DATA(lo_router) = zcl_api_router=>get_instance( ).
    lo_router->dispatch(
      io_server   = server
      iv_path     = server->request->get_header_field( '~path_info' )
      iv_method   = server->request->get_method( )
    ).
  ENDMETHOD.
ENDCLASS.

Keep this class thin. Its only job is to hand off to the router. The more logic you pile in here, the harder it becomes to test.

Building the Router

The router maps a path + HTTP method combination to a handler object. A simple table-driven approach works well here. You register routes at startup, and the dispatch method walks the table.

CLASS zcl_api_router DEFINITION PUBLIC CREATE PRIVATE.
  PUBLIC SECTION.
    CLASS-METHODS get_instance
      RETURNING VALUE(ro_instance) TYPE REF TO zcl_api_router.

    METHODS register
      IMPORTING
        iv_method  TYPE string
        iv_path    TYPE string
        io_handler TYPE REF TO zif_api_handler.

    METHODS dispatch
      IMPORTING
        io_server  TYPE REF TO if_http_server
        iv_path    TYPE string
        iv_method  TYPE string.

  PRIVATE SECTION.
    CLASS-DATA go_instance TYPE REF TO zcl_api_router.

    TYPES: BEGIN OF ty_route,
             method  TYPE string,
             path    TYPE string,
             handler TYPE REF TO zif_api_handler,
           END OF ty_route.
    DATA mt_routes TYPE TABLE OF ty_route.
ENDCLASS.

CLASS zcl_api_router IMPLEMENTATION.
  METHOD get_instance.
    IF go_instance IS NOT BOUND.
      go_instance = NEW #( ).
      " Register your routes here once
      go_instance->register(
        iv_method  = 'GET'
        iv_path    = '/orders'
        io_handler = NEW zcl_handler_orders( )
      ).
      go_instance->register(
        iv_method  = 'POST'
        iv_path    = '/orders'
        io_handler = NEW zcl_handler_orders( )
      ).
    ENDIF.
    ro_instance = go_instance.
  ENDMETHOD.

  METHOD register.
    APPEND VALUE #(
      method  = iv_method
      path    = iv_path
      handler = io_handler
    ) TO mt_routes.
  ENDMETHOD.

  METHOD dispatch.
    DATA(lv_path_clean) = to_lower( iv_path ).

    LOOP AT mt_routes INTO DATA(ls_route)
      WHERE method = iv_method
        AND path   = lv_path_clean.

      ls_route-handler->handle(
        io_server = io_server
      ).
      RETURN.
    ENDLOOP.

    " No route matched — return 404
    zcl_api_response=>send_error(
      io_server   = io_server
      iv_status   = 404
      iv_message  = 'Route not found'
    ).
  ENDMETHOD.
ENDCLASS.

Notice the singleton pattern on the router — you initialize routes once and reuse them across requests. That keeps your path registration in one place and avoids re-registering on every call.

The Handler Interface

Every resource handler implements a shared interface. This is the contract your router depends on:

INTERFACE zif_api_handler PUBLIC.
  METHODS handle
    IMPORTING
      io_server TYPE REF TO if_http_server.
ENDINTERFACE.

Inside each handler you read the method again if you need to differentiate GET vs POST behavior, parse the request body with server->request->get_cdata( ), call your business logic (BAPI, function module, or a proper service class), and write the response.

CLASS zcl_handler_orders DEFINITION PUBLIC FINAL CREATE PUBLIC.
  PUBLIC SECTION.
    INTERFACES zif_api_handler.
ENDCLASS.

CLASS zcl_handler_orders IMPLEMENTATION.
  METHOD zif_api_handler~handle.
    DATA(lv_method) = io_server->request->get_method( ).

    CASE lv_method.
      WHEN 'GET'.
        DATA(lt_orders) = zcl_order_service=>get_open_orders( ).
        zcl_api_response=>send_json(
          io_server = io_server
          iv_status = 200
          iv_data   = /ui2/cl_json=>serialize( lt_orders )
        ).

      WHEN 'POST'.
        DATA(lv_body)  = io_server->request->get_cdata( ).
        DATA(ls_input) = zcl_order_service=>create_order_from_json( lv_body ).
        zcl_api_response=>send_json(
          io_server = io_server
          iv_status = 201
          iv_data   = /ui2/cl_json=>serialize( ls_input )
        ).

      WHEN OTHERS.
        zcl_api_response=>send_error(
          io_server  = io_server
          iv_status  = 405
          iv_message = 'Method not allowed'
        ).
    ENDCASE.
  ENDMETHOD.
ENDCLASS.

Consistent Response Envelopes

Nothing kills an API integration faster than inconsistent error shapes. Define a response helper that always sends the same JSON structure:

CLASS zcl_api_response DEFINITION PUBLIC FINAL CREATE PRIVATE.
  PUBLIC SECTION.
    CLASS-METHODS send_json
      IMPORTING
        io_server TYPE REF TO if_http_server
        iv_status TYPE i
        iv_data   TYPE string.

    CLASS-METHODS send_error
      IMPORTING
        io_server  TYPE REF TO if_http_server
        iv_status  TYPE i
        iv_message TYPE string.
ENDCLASS.

CLASS zcl_api_response IMPLEMENTATION.
  METHOD send_json.
    io_server->response->set_status(
      code   = iv_status
      reason = 'OK'
    ).
    io_server->response->set_content_type( 'application/json; charset=utf-8' ).
    io_server->response->set_cdata( iv_data ).
  ENDMETHOD.

  METHOD send_error.
    DATA(lv_body) = |\{"error":\{"code":{ iv_status },"message":"{ iv_message }"\}\}|.
    io_server->response->set_status(
      code   = iv_status
      reason = 'Error'
    ).
    io_server->response->set_content_type( 'application/json; charset=utf-8' ).
    io_server->response->set_cdata( lv_body ).
  ENDMETHOD.
ENDCLASS.

Every consumer gets the same shape on failure: {"error":{"code":404,"message":"..."}}. No surprises, no parsing branches in the client.

Adding a Simple API Key Authentication Layer

Without SAP API Management, you handle auth yourself. The simplest viable approach for internal tools is a shared API key in a custom header, validated against a config table.

CLASS zcl_api_auth DEFINITION PUBLIC FINAL CREATE PRIVATE.
  PUBLIC SECTION.
    CLASS-METHODS is_authorized
      IMPORTING
        io_server        TYPE REF TO if_http_server
      RETURNING
        VALUE(rv_result) TYPE abap_bool.
ENDCLASS.

CLASS zcl_api_auth IMPLEMENTATION.
  METHOD is_authorized.
    DATA(lv_key) = io_server->request->get_header_field( 'x-api-key' ).

    IF lv_key IS INITIAL.
      rv_result = abap_false.
      RETURN.
    ENDIF.

    SELECT SINGLE @abap_true
      FROM zgw_api_keys
      WHERE api_key = @lv_key
        AND is_active = @abap_true
      INTO @rv_result.
  ENDMETHOD.
ENDCLASS.

Call this at the top of your dispatch method before routing. If it returns false, send a 401 immediately and skip the rest.

For production, consider salting and hashing stored keys — storing them in plain text in a DB table is fine for prototyping, not for anything customer-facing.

Path Parameters and Query Strings

Static path matching covers most cases, but eventually you'll need /orders/4500001234. The simplest approach without a regex engine is prefix matching: register /orders/ and let the handler parse whatever comes after the slash from the raw path info.

METHOD dispatch.
  LOOP AT mt_routes INTO DATA(ls_route).
    " Exact match first
    IF ls_route-path = lv_path_clean AND ls_route-method = iv_method.
      ls_route-handler->handle( io_server = io_server ).
      RETURN.
    ENDIF.

    " Prefix match for parameterized routes (e.g. /orders/)
    IF ls_route-path CS '*' AND iv_method = ls_route-method.
      DATA(lv_prefix) = ls_route-path(strlen( ls_route-path ) - 1).
      IF lv_path_clean(strlen( lv_prefix )) = lv_prefix.
        ls_route-handler->handle( io_server = io_server ).
        RETURN.
      ENDIF.
    ENDIF.
  ENDLOOP.
  ...
ENDMETHOD.

For query parameters, use server->request->get_form_field( 'status' ) — it works for query strings too, not just form data.

Where This Fits in Your Integration Architecture

This pattern works well when you have a controlled internal consumer set — a Node.js middleware, a Python automation script, or another SAP system calling over HTTP. If you're exposing services to the outside world, you'll want TLS termination, rate limiting, and proper identity management sitting in front of this layer.

If you're looking at how this fits alongside OData services, it's worth reading the overview in SAP Integration: RFC, OData, REST, IDoc Guide — that article covers when each protocol makes sense so you're not duplicating what OData already handles well.

If you're planning to expose some of these endpoints through CDS views instead, the ABAP OData V4 Service Build & Deploy Guide explains how to wire up CDS-based OData services that could complement or partially replace a custom gateway for read-heavy scenarios.

From a code quality perspective, keep your handler classes lean. Push all real business logic into service classes that you can test independently. The patterns in Testable ABAP Architecture for Unit Testing apply directly here — your handlers should be thin orchestrators, not business logic containers.

If you're injecting service dependencies into handlers rather than instantiating them inline, ABAP Dependency Injection Without a Framework shows how to do that cleanly without needing a DI container.

What You're Trading Off

Be honest with yourself about the limitations. This approach gives you zero observability out of the box — no built-in logging, no request tracing, no metrics dashboard. You need to instrument that yourself. Write request logs to an application log object (SLG1) or a custom Z-table at minimum.

You also get no automatic caching, no circuit breaking, and no consumer management. For a handful of trusted consumers on a stable internal network, those gaps are usually acceptable. For anything beyond that, spend the effort on proper API Management infrastructure.

What you do get is full control inside the ABAP stack, no middleware hop, and the ability to call any ABAP API — function modules, BAPIs, class methods — without a translation layer in between.

Summary

Building an ABAP REST API gateway without SAP API Management is entirely feasible for internal integration scenarios. The architecture is straightforward: an ICF handler hands off to a router, the router dispatches to resource-specific handler classes, and a shared response helper keeps your JSON contract consistent. Add a header-based API key check and you have a working, testable gateway in a few hundred lines of clean ABAP.

Start simple. Register five routes, prove the pattern works with your consumers, then add logging and more sophisticated auth as the usage grows. Don't over-engineer this on day one — that's how you end up with a framework nobody understands six months later.