amaannawab923 opened a new pull request, #45515:
URL: https://github.com/apache/superset/pull/45515
### SUMMARY
An extension can place a view inside a surface Superset already ships, but
it cannot add a screen of its own. `ViewContributions` has no entry for a
route, so an extension that needs a page — a settings screen, a queue, a report
— has no way to ask for one.
This adds one declaration that drives both halves of a page:
```json
"contributions": {
"pages": [{
"id": "acme.demo.queue",
"name": "Review queue",
"path": "/acme-demo/queue/",
"permission": { "resource": "Dashboard", "action": "read" }
}]
}
```
The frontend mounts it as a client-side route via `views.registerPage`;
`init_extensions` registers a view rendering the application shell at the same
path.
**The backend half is not optional.** A client-side route only exists once
the app is running, so a cold load, a refresh, a bookmark or any link from
outside reaches Flask directly and 404s. A page with only the frontend half
works for navigation that starts inside the app and nowhere else. The shell is
served for sub-paths too, since the frontend matches a page's path as a prefix.
Pages live in the existing view registry under a reserved location,
inheriting its disposal, reactivity and error-boundary machinery rather than
growing a second one beside it. `RouteSwitch` mounts them after Superset's own
routes, so a page colliding with a host route is never reached and the host
keeps its screen (with a warning logged). `isFrontendRoute` consults the
registry live, since extensions register long after its route dictionary is
built; `isHostRoute` is split out as the narrower question of whether the host
owns a path.
**Two decisions worth review:**
*Access uses `@has_access` against a permission the page declares.*
`flask_login.login_required` looks simpler and is not —
`login_manager.login_view` is never set, so an unauthenticated request fails to
build a redirect and the page answers 500 instead of sending the viewer to
login. Declaring a permission is required rather than defaulting, because the
insecure reading of an omitted field is the one nobody notices; a page names a
resource its own API already registers, since a freshly invented permission is
held by nobody until a role sync has run and the page would 403 for everyone
including Admin.
*Collisions are refused, not dropped*, each with a line naming both sides:
two pages sharing an id would build one view class and the second would never
reach the route table; two pages claiming one path would leave load order to
decide which answered; a page claiming a path Superset already serves would
shadow a real screen.
Paths are normalised to one trailing slash and validated — a
protocol-relative `//elsewhere/` would be an open redirect wherever the host
emitted it as a link, `..` escapes the prefix the path exists to establish, `/`
would be every page at once, and an empty or `.` segment is a path that is not
the one it looks like. A page id is validated because it becomes part of a
generated view class name, which Flask-AppBuilder also names the view's
permissions after.
**Two pre-existing registry defects are fixed here**, since contributed
pages are the first case where two parties are likely to pick the same id: a
second `registerView` under one id replaced the first silently, and the
replaced registration's `Disposable` deleted by id unconditionally — so
disposing it removed *the replacement*, meaning an extension tearing down its
own view took another extension's with it.
### TESTING INSTRUCTIONS
Automated:
```bash
pytest tests/unit_tests/extensions/
cd superset-extensions-cli && pytest tests/test_cli_build.py
cd superset-frontend && npm run test -- src/core/views
src/views/routes.test.tsx
```
`tests/unit_tests/extensions/test_page_discovery.py` is the end-to-end one:
it writes an extension to a temp directory, points `LOCAL_EXTENSIONS` at it,
and asserts real discovery finds the declared pages, each becomes a route with
its deep link, each renders the application shell with its bootstrap payload,
and each stays guarded.
Manual:
1. Add a `contributions.pages` entry to a local extension's `extension.json`
as above, with a `permission.resource` the extension's own API registers.
2. In the extension's frontend entry, call `views.registerPage` with the
same `path`.
3. Build the extension and restart Superset.
4. Navigate **directly** to the path (a cold load, not in-app navigation).
The contributed page renders inside Superset's chrome. A sub-path renders it
too. Logged out, you are redirected to login rather than given a 404 or a 500.
5. The host logs the pairing at startup:
`Serving extension page '<ext>:<page>' at '<path>', guarded by '<action>
on <resource>'.`
### ADDITIONAL INFORMATION
- [ ] Has associated issue:
- [ ] Required feature flags:
- [x] Changes UI
- [ ] Includes DB Migration
- [x] Introduces new feature or API
- [ ] Removes existing feature or API
Opened as a draft to start the conversation on the API shape before a SIP is
written. The contribution schema (`contributions.pages`),
`views.registerPage`/`getPages`, and the declared-permission contract are the
parts that would need SIP sign-off.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]