Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions endpoint_openapi/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
waiting the bot :)
1 change: 1 addition & 0 deletions endpoint_openapi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
16 changes: 16 additions & 0 deletions endpoint_openapi/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright 2026 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

{
"name": "Endpoint OpenAPI",
"summary": "OpenAPI docs for configurable endpoints.",
"version": "18.0.1.0.0",
"license": "LGPL-3",
"development_status": "Alpha",
"author": "Camptocamp,Odoo Community Association (OCA)",
"maintainers": ["simahawk"],
"website": "https://github.com/OCA/web-api",
"depends": ["endpoint", "openapi_doc_viewer"],
"data": ["views/endpoint_view.xml"],
"auto_install": True,
}
2 changes: 2 additions & 0 deletions endpoint_openapi/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import endpoint_mixin
from . import ir_http
24 changes: 24 additions & 0 deletions endpoint_openapi/models/endpoint_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2026 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

from odoo import models


class EndpointMixin(models.AbstractModel):
"""Describe endpoints for OpenAPI docs via `openapi.doc.mixin`."""

_name = "endpoint.mixin"
_inherit = ["endpoint.mixin", "openapi.doc.mixin"]

def _openapi_route_info(self):
"""Use the routing of the endpoint (route, method, auth)."""
route, routing, __ = self._get_routing_info()
return route, routing

def _openapi_api_doc(self):
"""Use the request content type of the endpoint for the request body."""
api_doc = super()._openapi_api_doc()
if self.request_content_type:
api_doc["content_type"] = self.request_content_type
return api_doc
67 changes: 67 additions & 0 deletions endpoint_openapi/models/ir_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright 2026 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

import functools

from odoo import models

from odoo.addons.openapi_doc_base.sources import DocSource

NO_GROUP = "default"


class IrHttp(models.AbstractModel):
"""One docs source per endpoint model and route group."""

_inherit = "ir.http"

def _openapi_endpoint_models(self):
"""Concrete models using `endpoint.mixin` (eg: `edi.endpoint`)."""
names = self.env.registry.descendants(["endpoint.mixin"], "_inherit")
return [n for n in sorted(names) if not self.env[n]._abstract]

def _openapi_doc_sources(self):
"""Add a source per model (category) and route group (app)."""
sources = super()._openapi_doc_sources()
for model_name in self._openapi_endpoint_models():
model = self.env[model_name].sudo()
groups = model.search(model._openapi_documented_domain()).mapped(
lambda r: r.route_group or NO_GROUP
)
for group in sorted(set(groups)):
sources.append(
DocSource(
key=f"{model_name}.{group}",
title=f"{model._description}: {group}",
build=functools.partial(
self._openapi_build_endpoint_doc, model_name, group
),
category=model_name,
app=group,
)
)
return sources

def _openapi_build_endpoint_doc(self, model_name, group, env):
"""Build the document of the endpoints of a model and route group."""
model = env[model_name].sudo()
domain = model._openapi_documented_domain()
if group == NO_GROUP:
domain.append(("route_group", "in", (False, "")))
else:
domain.append(("route_group", "=", group))
records = model.search(domain, order="route")
return records._openapi_build_doc(title=f"{model._description}: {group}")

def _openapi_doc_category_label(self, category):
"""Show the description of endpoint models (eg: `Endpoint`)."""
if category in self._openapi_endpoint_models():
return self.env[category]._description
return super()._openapi_doc_category_label(category)

def _openapi_security_scheme(self, auth):
"""Endpoints use `user_endpoint` for session based auth."""
if auth == "user_endpoint":
auth = "user"
return super()._openapi_security_scheme(auth)
3 changes: 3 additions & 0 deletions endpoint_openapi/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
1 change: 1 addition & 0 deletions endpoint_openapi/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Simone Orsi \<<simone.orsi@camptocamp.com>\>
8 changes: 8 additions & 0 deletions endpoint_openapi/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Document configurable endpoints (`endpoint.endpoint` and any model using
`endpoint.mixin`, eg: `edi.endpoint`) with OpenAPI and browse them with Swagger UI.

Endpoints are documented only when enabled on the record:
they might be meant for internal use only.

Technically, it plugs `endpoint.mixin` into `openapi.doc.mixin` (from `openapi_doc_base`)
and registers one docs source per model and route group.
3 changes: 3 additions & 0 deletions endpoint_openapi/readme/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Show the API docs fields on the specific views of other models
using `endpoint.mixin` (eg: `edi.endpoint`), if they don't inherit the mixin view.
- Generate schemas from the code of the endpoint, when possible.
18 changes: 18 additions & 0 deletions endpoint_openapi/readme/USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
On the endpoint form, open the *API docs* tab:

- enable *Show in API docs*
- optionally add a description, tags, and the JSON schemas of the input
(params or body) and of the output (response)

Then open *Settings > Technical > API docs* (`/openapi/docs`, from `openapi_doc_viewer`):

- one document per model (category) and route group (app)
- use the links at the top to filter by model and route group
- docs are visible to the *API docs: viewer* group. To restrict endpoint docs further,
extend `ir.http._openapi_doc_category_groups` for the model (category)

Each document is also available as JSON at
`/openapi/docs/<model>.<route group>.json`.

The request content type of the endpoint is used for the request body,
the auth type for the security scheme.
Binary file added endpoint_openapi/static/description/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions endpoint_openapi/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_docs
146 changes: 146 additions & 0 deletions endpoint_openapi/tests/test_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Copyright 2026 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

import json

from odoo import exceptions
from odoo.tests.common import HttpCase, new_test_user, tagged

SCHEMA = {
"type": "object",
"required": ["ref"],
"properties": {"ref": {"type": "string"}},
}


class CommonDocs:
@classmethod
def _create_endpoints(cls):
model = cls.env["endpoint.endpoint"]
common = {"exec_mode": "code", "code_snippet": "result = {}"}
cls.ep_import = model.create(
dict(
common,
name="Import orders",
route="/test_doc/orders",
request_method="POST",
request_content_type="application/xml",
route_group="orders",
api_doc_enabled=True,
api_doc_description="Import orders from XML.",
api_doc_tags="orders, import",
api_doc_input_schema=json.dumps(SCHEMA),
)
)
cls.ep_status = model.create(
dict(
common,
name="Order status",
route="/test_doc/status",
request_method="GET",
route_group="orders",
api_doc_enabled=True,
api_doc_output_schema=json.dumps(SCHEMA),
)
)
cls.ep_hidden = model.create(
dict(
common,
name="Internal",
route="/test_doc/internal",
request_method="GET",
route_group="orders",
)
)
cls.ep_other = model.create(
dict(
common,
name="Ping",
route="/test_doc/ping",
request_method="GET",
auth_type="public",
exec_as_user_id=cls.env.ref("base.user_admin").id,
api_doc_enabled=True,
)
)


@tagged("-at_install", "post_install")
class TestDocs(HttpCase, CommonDocs):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._create_endpoints()
cls.ir_http = cls.env["ir.http"]
new_test_user(
cls.env,
login="doc_user",
password="doc_user",
groups="base.group_user,openapi_doc_base.group_openapi_doc_viewer",
)

def test_sources(self):
"""There is one source per model and route group (`default` if none)."""
sources = self.ir_http._openapi_doc_sources_for(category="endpoint.endpoint")
self.assertEqual(
[(s.key, s.app) for s in sources],
[
("endpoint.endpoint.default", "default"),
("endpoint.endpoint.orders", "orders"),
],
)

def test_document(self):
"""A group's document lists the enabled endpoints w/ schemas, auth and tags."""
doc = self.ir_http._openapi_doc("endpoint.endpoint.orders")
self.assertEqual(sorted(doc["paths"]), ["/test_doc/orders", "/test_doc/status"])
post = doc["paths"]["/test_doc/orders"]["post"]
self.assertEqual(post["summary"], "Import orders")
self.assertEqual(post["description"], "Import orders from XML.")
self.assertEqual(post["tags"], ["orders", "import"])
self.assertEqual(
post["requestBody"]["content"]["application/xml"]["schema"], SCHEMA
)
self.assertEqual(post["security"], [{"odooSession": []}])
get = doc["paths"]["/test_doc/status"]["get"]
self.assertEqual(
get["responses"]["200"]["content"]["application/json"]["schema"], SCHEMA
)
doc = self.ir_http._openapi_doc("endpoint.endpoint.default")
op = doc["paths"]["/test_doc/ping"]["get"]
self.assertNotIn("security", op)

def test_doc_refreshed(self):
"""Disabling the docs of an endpoint removes it from its document."""
self.assertIn(
"/test_doc/status",
self.ir_http._openapi_doc("endpoint.endpoint.orders")["paths"],
)
self.ep_status.api_doc_enabled = False
self.assertNotIn(
"/test_doc/status",
self.ir_http._openapi_doc("endpoint.endpoint.orders")["paths"],
)

def test_schema_validation(self):
"""Invalid schemas (not JSON objects) are refused."""
with self.assertRaises(exceptions.ValidationError):
self.ep_status.api_doc_output_schema = "[1, 2]"
with self.assertRaises(exceptions.ValidationError):
self.ep_status.api_doc_output_schema = "{nope"

def test_http(self):
"""Viewers get the endpoint docs; internal users w/o the group get a 404."""
self.authenticate("doc_user", "doc_user")
response = self.url_open("/openapi/docs?category=endpoint.endpoint")
self.assertEqual(response.status_code, 200)
self.assertIn("/openapi/docs/endpoint.endpoint.orders.json", response.text)
self.assertIn("app=orders", response.text)
response = self.url_open("/openapi/docs/endpoint.endpoint.orders.json")
self.assertIn("/test_doc/orders", response.json()["paths"])
# Internal users w/o the viewer group can't see the docs
new_test_user(self.env, login="internal", password="internal")
self.authenticate("internal", "internal")
response = self.url_open("/openapi/docs/endpoint.endpoint.orders.json")
self.assertEqual(response.status_code, 404)
33 changes: 33 additions & 0 deletions endpoint_openapi/views/endpoint_view.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2026 Camptocamp SA
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -->
<odoo>
<record model="ir.ui.view" id="endpoint_mixin_form_view">
<field name="name">endpoint.mixin.form.openapi</field>
<field name="model">endpoint.mixin</field>
<field name="inherit_id" ref="endpoint.endpoint_mixin_form_view" />
<field name="arch" type="xml">
<notebook position="inside">
<page name="api_doc" string="API docs">
<group>
<field name="api_doc_enabled" />
<field name="api_doc_tags" invisible="not api_doc_enabled" />
<field
name="api_doc_description"
invisible="not api_doc_enabled"
/>
</group>
<group invisible="not api_doc_enabled">
<field name="api_doc_input_schema" widget="ace" />
<field name="api_doc_output_schema" widget="ace" />
</group>
<a
href="/openapi/docs?category=endpoint.endpoint"
target="_blank"
invisible="not api_doc_enabled"
>Open API docs</a>
</page>
</notebook>
</field>
</record>
</odoo>
2 changes: 2 additions & 0 deletions test-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
odoo-addon-openapi_doc_viewer @ git+https://github.com/OCA/web-api.git@refs/pull/183/head#subdirectory=openapi_doc_viewer
odoo-addon-openapi_doc_base @ git+https://github.com/OCA/web-api.git@refs/pull/181/head#subdirectory=openapi_doc_base
Loading