Skip to content
Closed
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
120 changes: 120 additions & 0 deletions api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -13058,6 +13058,126 @@
}
},
"/links": {
"get": {
"description": "Returns a paginated, ID-ordered dependency graph for one workspace. Only non-custom-field item-to-item links with both endpoints in that workspace are returned. Cross-workspace and non-item links are excluded so link metadata from another workspace or permission domain cannot leak.",
"parameters": [
{
"description": "Workspace ID",
"in": "query",
"name": "workspace_id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Page number (1-based)",
"in": "query",
"name": "page",
"schema": {
"minimum": 1,
"type": "integer"
}
},
{
"description": "Links per page",
"in": "query",
"name": "limit",
"schema": {
"maximum": 100,
"minimum": 1,
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/internal_restapi_v1_handlers.PaginatedResponse"
},
{
"properties": {
"data": {
"items": {
"$ref": "#/components/schemas/models.ItemLink"
},
"type": "array"
}
},
"type": "object"
}
]
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
}
}
},
"description": "Invalid workspace ID or pagination"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
}
}
},
"description": "Token lacks the items:read scope"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
}
}
},
"description": "Workspace not found or not visible to caller"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"BearerAuth": []
}
],
"summary": "List a workspace's direct work-item links",
"tags": [
"links",
"workspaces"
]
},
"post": {
"description": "Creates a link between two entities (item/page/test_case). The link type must allow the given entity types; pages must share a workspace with the source item.",
"requestBody": {
Expand Down
75 changes: 75 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8412,6 +8412,81 @@ paths:
tags:
- links
/links:
get:
description: Returns a paginated, ID-ordered dependency graph for one workspace.
Only non-custom-field item-to-item links with both endpoints in that workspace
are returned. Cross-workspace and non-item links are excluded so link metadata
from another workspace or permission domain cannot leak.
parameters:
- description: Workspace ID
in: query
name: workspace_id
required: true
schema:
type: integer
- description: Page number (1-based)
in: query
name: page
schema:
minimum: 1
type: integer
- description: Links per page
in: query
name: limit
schema:
maximum: 100
minimum: 1
type: integer
responses:
"200":
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/internal_restapi_v1_handlers.PaginatedResponse'
- properties:
data:
items:
$ref: '#/components/schemas/models.ItemLink'
type: array
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
description: Invalid workspace ID or pagination
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
description: Token lacks the items:read scope
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
description: Workspace not found or not visible to caller
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
description: Internal Server Error
security:
- BearerAuth: []
summary: List a workspace's direct work-item links
tags:
- links
- workspaces
post:
description: Creates a link between two entities (item/page/test_case). The
link type must allow the given entity types; pages must share a workspace
Expand Down
83 changes: 83 additions & 0 deletions internal/restapi/v1/handlers/links.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"errors"
"math"
"net/http"
"strconv"
"strings"
Expand Down Expand Up @@ -48,6 +49,45 @@ type linkCreateRequest struct {

const maxBatchLinkItems = 100

// parseWorkspaceLinkPagination rejects page values whose offset would
// overflow an int before the workspace link service performs any database
// access. The shared parser remains permissive for legacy endpoints.
func parseWorkspaceLinkPagination(r *http.Request) (restapi.PaginationParams, error) {
query := r.URL.Query()
if query.Has("sort") || query.Has("order") {
return restapi.PaginationParams{}, errors.New("sort and order are not supported; links are ordered by ID ascending")
}

pagination := restapi.PaginationParams{
Page: restapi.DefaultPage,
Limit: restapi.DefaultLimit,
}
limitRaw := strings.TrimSpace(query.Get("limit"))
if query.Has("limit") {
limit, err := strconv.Atoi(limitRaw)
if err != nil || limit <= 0 || limit > restapi.MaxLimit {
return restapi.PaginationParams{}, errors.New("limit must be an integer between 1 and 100")
}
pagination.Limit = limit
}
pageRaw := strings.TrimSpace(query.Get("page"))
if !query.Has("page") {
pagination.Offset = 0
return pagination, nil
}

page, err := strconv.Atoi(pageRaw)
if err != nil || page <= 0 {
return restapi.PaginationParams{}, errors.New("page must be a positive integer")
}
if page-1 > math.MaxInt/pagination.Limit {
return restapi.PaginationParams{}, errors.New("page is too large")
}
pagination.Page = page
pagination.Offset = (page - 1) * pagination.Limit
return pagination, nil
}

type batchItemLinksResponse struct {
ItemID int `json:"item_id"`
Outgoing []models.ItemLink `json:"outgoing"`
Expand Down Expand Up @@ -218,6 +258,49 @@ func (h *LinkHandler) GetLinksBatch(w http.ResponseWriter, r *http.Request) {
h.RespondPaginated(w, response, pagination, total)
}

// ListWorkspaceItemLinks handles GET /rest/api/v1/links?workspace_id={id}.
//
// @Summary List a workspace's direct work-item links
// @Description Returns a paginated, ID-ordered dependency graph for one workspace. Only non-custom-field item-to-item links with both endpoints in that workspace are returned. Cross-workspace and non-item links are excluded so link metadata from another workspace or permission domain cannot leak.
// @Tags links, workspaces
// @Produce json
// @Security BearerAuth
// @Param workspace_id query int true "Workspace ID"
// @Param page query int false "Page number (1-based)" minimum(1)
// @Param limit query int false "Links per page" minimum(1) maximum(100)
// @Success 200 {object} handlers.PaginatedResponse{data=[]models.ItemLink}
// @Failure 400 {object} handlers.ErrorResponse "Invalid workspace ID or pagination"
// @Failure 401 {object} handlers.ErrorResponse
// @Failure 403 {object} handlers.ErrorResponse "Token lacks the items:read scope"
// @Failure 404 {object} handlers.ErrorResponse "Workspace not found or not visible to caller"
// @Failure 500 {object} handlers.ErrorResponse
// @Router /links [get]
func (h *LinkHandler) ListWorkspaceItemLinks(w http.ResponseWriter, r *http.Request) {
user, ok := h.RequireAuth(w, r)
if !ok {
return
}
workspaceID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("workspace_id")))
if err != nil || workspaceID <= 0 {
h.RespondError(w, r, restapi.NewAPIError(http.StatusBadRequest, restapi.ErrCodeInvalidInput, "workspace_id must be a positive integer"))
return
}
pagination, err := parseWorkspaceLinkPagination(r)
if err != nil {
h.RespondError(w, r, restapi.NewAPIError(http.StatusBadRequest, restapi.ErrCodeInvalidInput, err.Error()))
return
}

links, total, err := h.svc.ListWorkspaceItemLinksWithChecks(
r.Context(), user.ID, workspaceID, pagination.Limit, pagination.Offset,
)
if err != nil {
h.respondLinkServiceError(w, r, "workspace", err)
return
}
h.RespondPaginated(w, links, pagination, total)
}

// CreateLink handles POST /rest/api/v1/links
//
// CreateLink creates a cross-entity link. Scope: items:write. The
Expand Down
1 change: 1 addition & 0 deletions internal/restapi/v1/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ func RegisterRoutes(deps restapi.Deps) {

v1.HandleWithMiddleware("GET /link-types", linkHandler.ListLinkTypes, bearerAuth.RequirePermission("items:read"))
v1.HandleWithMiddleware("GET /links/batch", linkHandler.GetLinksBatch, bearerAuth.RequirePermission("items:read"))
v1.HandleWithMiddleware("GET /links", linkHandler.ListWorkspaceItemLinks, bearerAuth.RequirePermission("items:read"))
v1.HandleWithMiddleware("POST /links", linkHandler.CreateLink, bearerAuth.RequirePermission("items:write"))
v1.HandleWithMiddleware("DELETE /links/{id}", linkHandler.DeleteLink, bearerAuth.RequirePermission("items:write"), router.RequireNumericID)
v1.HandleWithMiddleware("GET /items/{id}/links", linkHandler.GetLinksForEntity, bearerAuth.RequirePermission("items:read"), router.RequireNumericID)
Expand Down
9 changes: 8 additions & 1 deletion internal/services/item_link_orchestration.go
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,13 @@ func getLinksWhereContext(ctx context.Context, db database.Database, whereClause
}

func itemLinksWhereQuery(whereClause string) string {
return itemLinksWhereQueryWithOrder(whereClause, "lt.name, il.created_at DESC")
}

// itemLinksWhereQueryWithOrder shares the joined response projection between
// link reads that need different stable orderings. orderBy is always supplied
// by service-owned constants, never request input.
func itemLinksWhereQueryWithOrder(whereClause, orderBy string) string {
return `
SELECT il.id, il.link_type_id, il.source_type, il.source_id, il.target_type, il.target_id,
il.created_by, il.created_at,
Expand Down Expand Up @@ -1256,7 +1263,7 @@ func itemLinksWhereQuery(whereClause string) string {
LEFT JOIN workspaces tpw ON tp.workspace_id = tpw.id
LEFT JOIN custom_field_definitions cfd ON il.custom_field_id = cfd.id
WHERE ` + whereClause + `
ORDER BY lt.name, il.created_at DESC
ORDER BY ` + orderBy + `
`
}

Expand Down
Loading
Loading