Skip to content

Add Outbound Routes REST API (#686)#713

Open
Diverious wants to merge 1 commit into
dOpensource:qafrom
Diverious:feature-outbound-routes-api-686
Open

Add Outbound Routes REST API (#686)#713
Diverious wants to merge 1 commit into
dOpensource:qafrom
Diverious:feature-outbound-routes-api-686

Conversation

@Diverious
Copy link
Copy Markdown

Description

Closes #686.

Outbound routes (rows in dr_rules with groupid == FLT_OUTBOUND or in [FLT_LCR_MIN, FLT_FWD_MIN)) were only manageable via the Flask GUI at gui/dsiprouter.py:1710-1932. This PR adds /api/v1/outboundroutes with full GUI parity, including LCR (from-prefix) routing, so any route createable in the GUI is now createable via the API and vice versa — unblocking automation, scripting, and customer integration use cases.

API surface

Method URL Notes
GET /api/v1/outboundroutes list (simple + LCR)
GET /api/v1/outboundroutes/<ruleid> get one (also ?ruleid=N)
POST /api/v1/outboundroutes create
PUT /api/v1/outboundroutes/<ruleid> update (also ?ruleid=N)
DELETE /api/v1/outboundroutes/<ruleid> delete (also ?ruleid=N)

Path-style URLs for single-record routes match carriergroups and endpointgroups; query-string ?ruleid=N is also accepted for symmetry with the existing inboundmapping API. PUT and DELETE are declared on the no-path-arg URL too, so the handler's "ruleid is required" 400 path is reachable (otherwise Flask short-circuits with 405 before the handler runs).

Request body fields (all optional except gwgroupid on POST):

Field Notes
name friendly name (stored name:<value> in description)
from_prefix presence promotes the rule to LCR routing
prefix To-prefix (required when from_prefix is set, mirrors the GUI JS validator at gui/static/js/outboundroutes.js:19-28)
timerec Kamailio time-recurrence pattern
priority int, default 0
routeid custom Kamailio route name
gwgroupid carrier group id; required on POST, "0" rejected (matches GUI at gui/dsiprouter.py:1740)

Response uses the standard createApiResponse envelope {error, msg, kamreload, dsipreload, data}. Every mutation flips shared-memory kam_reload_required so the reload daemon fires drouting.reload on the next tick. INFO/ERROR events go through the IO class (gui/shared.py:164+) like every other API handler.

LCR semantics

Mirrors addUpateOutboundRoutes (gui/dsiprouter.py:1710-1875) exactly:

Case Behavior
No from_prefix groupid = FLT_OUTBOUND (8000), no paired dsip_lcr row
POST with from_prefix groupid auto-allocated to max(dr_groupid in [FLT_LCR_MIN, FLT_FWD_MIN)) + 1 (or FLT_LCR_MIN if none); paired dsip_lcr row with pattern = '<from_prefix>-<prefix>'
PUT promote (from_prefix set on simple route) → new groupid in LCR range, new dsip_lcr row
PUT demote (clear from_prefix on LCR route) → groupid reset to FLT_OUTBOUND, paired dsip_lcr row deleted
PUT in-place (change from_prefix on LCR route) → dsip_lcr pattern + from_prefix updated, groupid unchanged
DELETE paired dsip_lcr rows removed first, then the dr_rules row

The PUT handler fetches the existing row before deciding the branch, which sidesteps the GUI's known TODO at gui/dsiprouter.py:1845 ("not sure how to correlate stale LCR entries without old from-prefix").

gwlist is serialized as '#<gwgroupid>' to match the GUI's storage format.

Type of change

  • New feature (non-breaking change which adds functionality)
  • Bug fix
  • Breaking change
  • This change requires a documentation update (included in this PR)

How Has This Been Tested?

  • Test Script Committed
  • Instructions Provided Below

Automated coverage

testing/19.sh extended with 29 integration cases covering every code path: every HTTP method, every status code (200 / 400 / 404 / 401), every validator branch, both ID conventions (path-style and ?ruleid=), all four LCR update branches, kamreload-flag verification, and DB-state asserts after each mutation via mysql SELECT. Assertions use exact-code comparisons (-ne <expected>) instead of the weaker -ne 200 idiom in the existing inboundmapping tests, so an unexpected 500 fails loudly. cleanupHandler() extended with description LIKE 'name:Test Outbound %' and from_prefix LIKE '999%' filters so production LCR rules are never touched.

testing/manual/outboundroutes_smoke.py exercises the helper layer directly (no HTTP) against the live DB; useful for diagnosing whether a failure is in the routing layer or the business logic.

Verified locally

End-to-end against a containerized MySQL with the kamailio schema: 67/67 assertions passing across helper-layer (41) and HTTP-layer (26) harnesses. The gwgroupid="0" validation case caught a real Python truthiness bug (not "0" is False because the string is non-empty) that an earlier draft would have shipped.

Reproducing

# On a fresh dSIPRouter install host:
git fetch origin pull/<this-PR>/head:feature-outbound-routes-api-686
git checkout feature-outbound-routes-api-686
sudo cp -r gui/modules/api/outboundroutes /etc/dsiprouter/gui/modules/api/
sudo cp gui/dsiprouter.py /etc/dsiprouter/gui/dsiprouter.py
sudo systemctl restart dsiprouter
cd testing && make run UNIT=19.sh

Test Configuration

  • OS/Distro: any supported (Debian 12 / Ubuntu 24.04 / CentOS 9 / etc.)
  • Software: dSIPRouter v0.78 install + MySQL kamailio schema + live Kamailio for end-to-end drouting.reload verification

Documentation

File Change
docs/source/dev/modules.rst automodule entries for outboundroutes.routes and outboundroutes.functions
docs/source/user/api.rst curl examples (list / create-simple / create-LCR / update / delete)
docs/source/user/global_outbound_routes.rst cross-link from GUI walkthrough to the API user page

The Flask blueprint docstring is auto-extracted into routes/summary.rst and routes/details.rst via the existing .. qrefflask:: and .. autoflask:: directives in docs/source/conf.py:46-49, so the public API reference picks the endpoint up with no additional Sphinx configuration.

Postman collection (testing/api/dsiprouter.postman_collection.json) gains a new outboundroutes folder with six requests (list, get-by-id, POST simple, POST LCR, PUT, DELETE).

Known limitations (match GUI behavior; out of scope for this PR)

  • nextLcrGroupid concurrency: two simultaneous LCR POSTs could be assigned the same groupid (no SELECT ... FOR UPDATE). Same race exists in the GUI at gui/dsiprouter.py:1754-1762, 1811-1818.
  • description codec: name fields containing , or : would break the key:value,key:value encoding. GUI has no validation here either.
  • gwgroupid existence not validated against dr_gw_lists — matches the inboundmapping API's same TODO at gui/modules/api/api_routes.py:565.
  • Carrier-group display name not returned — only gwgroupid. The GUI joins dr_gw_lists for the friendly name; the API returns just the id.
  • dsip_hardfwd / dsip_failfwd attachment not exposed — the GUI's /outboundroutes page doesn't expose it either.

Checklist

  • My code follows the Contributing guidelines of this project
  • This PR is not a duplicate of another open PR
  • I have performed a self-review of my code
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • My code passes integration testing on a live system

Outbound routes (rows in dr_rules with groupid == FLT_OUTBOUND or in
[FLT_LCR_MIN, FLT_FWD_MIN)) were only manageable via the Flask GUI at
gui/dsiprouter.py:1710-1932; this adds /api/v1/outboundroutes with full
GUI parity, including LCR (from-prefix) routing. Any route createable
in the GUI is now createable via the API and vice versa, unblocking
automation, scripting, and customer integration use cases.

New module
----------
gui/modules/api/outboundroutes/
  __init__.py    package marker
  functions.py   business logic with no Flask deps, callable
                 independently for unit-style testing:
                   validateOutboundRouteBody, nextLcrGroupid,
                   serializeOutboundRoute, createOutboundRoute,
                   updateOutboundRoute, deleteOutboundRoute
  routes.py      handleOutboundRoutes Flask handler for
                 GET/POST/PUT/DELETE on both /api/v1/outboundroutes
                 and /api/v1/outboundroutes/<int:ruleid>; gated by
                 @api_security and carrying a full Sphinx httpdomain
                 docstring with Request/Response Payload examples

Wired into gui/dsiprouter.py with the same idiom the licensemanager,
carriergroups, and auth blueprints use: import, app.register_blueprint,
csrf.exempt -- three single-line additions.

API surface
-----------
  GET    /api/v1/outboundroutes               list (simple + LCR)
  GET    /api/v1/outboundroutes/<ruleid>      get one (also ?ruleid=N)
  POST   /api/v1/outboundroutes               create
  PUT    /api/v1/outboundroutes/<ruleid>      update (also ?ruleid=N)
  DELETE /api/v1/outboundroutes/<ruleid>      delete (also ?ruleid=N)

Path-style URLs for single-record routes match carriergroups and
endpointgroups; ?ruleid=N is also accepted for symmetry with the
existing inboundmapping API idiom. PUT and DELETE are also declared on
the no-path-arg URL so the handler's "ruleid is required" 400 path is
reachable -- otherwise Flask would short-circuit with 405 before the
handler ran.

Request body fields (all optional except gwgroupid on POST):
  name          friendly name (stored 'name:<value>' in description)
  from_prefix   presence promotes the rule to LCR routing
  prefix        To-prefix (required when from_prefix is set, mirrors
                the GUI JS validator at
                gui/static/js/outboundroutes.js:19-28)
  timerec       Kamailio time-recurrence pattern
  priority      int, default 0
  routeid       custom Kamailio route name
  gwgroupid     carrier group id; required on POST, '0' rejected
                (matches the GUI at gui/dsiprouter.py:1740 which
                filters '0' to mean 'no carrier group')

Response uses the standard createApiResponse envelope
{error, msg, kamreload, dsipreload, data, status_code}. Every mutation
flips shared-memory kam_reload_required so the reload daemon fires
drouting.reload on the next tick. INFO/ERROR events go through the IO
class (gui/shared.py:164+) the same way every other API handler logs.

LCR (Least Cost Routing) semantics
----------------------------------
Mirrors addUpateOutboundRoutes (gui/dsiprouter.py:1710-1875) exactly:

  No from_prefix:
    groupid = FLT_OUTBOUND (8000), no paired dsip_lcr row.
  POST with from_prefix:
    groupid auto-allocated to max(dr_groupid in
    [FLT_LCR_MIN, FLT_FWD_MIN)) + 1, or FLT_LCR_MIN if none exist;
    paired dsip_lcr row inserted with
    pattern = '<from_prefix>-<prefix>'.
  PUT promote (set from_prefix on a simple route):
    new groupid in LCR range, new dsip_lcr row.
  PUT demote (clear from_prefix on an LCR route):
    groupid reset to FLT_OUTBOUND, paired dsip_lcr row deleted.
  PUT in-place (change from_prefix on an LCR route):
    dsip_lcr pattern + from_prefix updated, groupid unchanged.
  DELETE:
    paired dsip_lcr rows matched by dr_groupid removed first, then
    the dr_rules row.

The PUT handler fetches the existing row before deciding which branch
to take, which sidesteps the GUI's known limitation at
gui/dsiprouter.py:1845 ("not sure how to correlate stale LCR entries
without old from-prefix") by always knowing the current groupid and
from_prefix before mutating.

gwlist is serialized as '#<gwgroupid>' (gateway-group reference) to
match the GUI's storage format.

Testing
-------
testing/19.sh extended with 29 integration cases covering every code
path: every HTTP method, every status code (200/400/404/401), every
validator branch, both ID conventions (path-style and ?ruleid=), all
four LCR update branches, kamreload-flag verification, and DB-state
asserts after each mutation via mysql SELECT. Assertions use exact
codes (-ne <expected>) instead of the weaker -ne 200 idiom in the
existing inboundmapping tests, so an unexpected 500 fails loudly.
cleanupHandler() extended with description and from_prefix LIKE
filters scoped to test-row markers ('name:Test Outbound %' and
'999%') so production LCR rules are never touched.

testing/manual/outboundroutes_smoke.py exercises the helper layer
directly (no HTTP) against the live DB. Useful for diagnosing whether
a failure is in the routing layer or the business logic; names use
the same 'Test Outbound %' / '999%' markers so any rows leaked on
failure are swept by the next testing/19.sh run.

Verified locally end-to-end against a containerized MySQL with the
kamailio schema: 67/67 assertions passing across helper-layer (41)
and HTTP-layer (26) harnesses. The 'gwgroupid="0"' validation case
caught a real Python truthiness bug ('not "0"' is False because the
string is non-empty) that an earlier draft would have shipped.

Postman
-------
testing/api/dsiprouter.postman_collection.json grows a new
outboundroutes folder with six requests: list, get-by-id, POST simple,
POST LCR, PUT, DELETE -- each with example bodies that exercise the
canonical request shape.

Documentation
-------------
docs/source/dev/modules.rst                  automodule entries for
                                             routes and functions
docs/source/user/api.rst                     curl examples (list /
                                             create-simple /
                                             create-LCR / update /
                                             delete)
docs/source/user/global_outbound_routes.rst  cross-link from the GUI
                                             walkthrough to the API
                                             user page

The blueprint docstring is auto-extracted into Sphinx
routes/summary.rst and routes/details.rst via the existing
.. qrefflask:: and .. autoflask:: directives at
docs/source/conf.py:46-49, so the public API reference picks the
endpoint up with no additional Sphinx configuration.

Known limitations (match GUI behavior; out of scope for this PR)
----------------------------------------------------------------
* nextLcrGroupid concurrency: two simultaneous LCR POSTs could be
  assigned the same groupid (no SELECT ... FOR UPDATE). Same race
  exists in the GUI handler at gui/dsiprouter.py:1754-1762 and
  1811-1818.
* description codec: name fields containing ',' or ':' would break
  the key:value,key:value description encoding. GUI has no
  validation here either.
* gwgroupid existence is not validated against dr_gw_lists. Matches
  the inboundmapping API's same TODO at
  gui/modules/api/api_routes.py:565.
* Carrier-group display name is not returned in the response; only
  gwgroupid. The GUI joins dr_gw_lists for the friendly name; the
  API returns just the id.
* dsip_hardfwd / dsip_failfwd attachment to outbound routes is not
  exposed, because the GUI's /outboundroutes page doesn't expose it
  either.

Closes dOpensource#686
@chelseatcarter
Copy link
Copy Markdown
Collaborator

Can one of the admins verify this patch?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants