REST endpoints в ScriptRunner позволяют создавать кастомные API для интеграций, дашбордов и автоматизации без разработки плагинов. В этой статье разберём детально, как создавать endpoints, какие типы ответов можно возвращать, как работать с параметрами и заголовками, и что именно можно выводить через REST API. Материал основан на практическом опыте создания сложных endpoints для реальных проектов.
Что такое REST endpoints в ScriptRunner
REST endpoints в ScriptRunner — это кастомные HTTP-эндпоинты, которые создаются через Groovy-скрипты.
Они доступны по адресу /rest/scriptrunner/latest/custom/{endpoint-name} и могут обрабатывать GET, POST, PUT, DELETE запросы.
Основные преимущества:
- Быстрое создание — без разработки и компиляции плагинов
- Гибкость — можно возвращать JSON, HTML, XML, текст
- Доступ к Jira API — полный доступ к компонентам Jira через ComponentAccessor
- Интеграции — легко интегрироваться с внешними системами
- Кастомные интерфейсы — создание HTML-страниц и виджетов
Создание простого REST endpoint
Для создания REST endpoint в ScriptRunner:
- Перейдите в Administration → ScriptRunner → REST → Scripted REST Endpoints
- Нажмите Create endpoint
- Укажите имя endpoint (например,
myCustomApi) - Выберите HTTP метод (GET, POST, PUT, DELETE)
- Укажите путь (например,
/api/dataили/api/data/{id}) - Напишите Groovy-скрипт
Простейший пример GET endpoint:
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.transform.BaseScript
import javax.ws.rs.core.Response
@BaseScript CustomEndpointDelegate delegate
myCustomApi(httpMethod: "GET") { MultivaluedMap queryParams ->
def result = [
message: "Hello from ScriptRunner REST API",
timestamp: new Date().toString()
]
return Response.ok(new groovy.json.JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
Этот endpoint будет доступен по адресу: GET /rest/scriptrunner/latest/custom/myCustomApi
Типы ответов: что можно выводить
REST endpoints в ScriptRunner могут возвращать различные типы данных. Рассмотрим основные варианты.
JSON ответы
JSON — самый распространённый формат для API. Используйте JsonBuilder для создания JSON:
import groovy.json.JsonBuilder
import javax.ws.rs.core.Response
getIssueInfo(httpMethod: "GET") { MultivaluedMap queryParams ->
def issueKey = queryParams.getFirst("issueKey")
if (!issueKey) {
return Response.status(400)
.entity(new JsonBuilder([error: "issueKey parameter is required"]).toString())
.type("application/json")
.build()
}
def issueManager = ComponentAccessor.getIssueManager()
def issue = issueManager.getIssueObject(issueKey)
if (!issue) {
return Response.status(404)
.entity(new JsonBuilder([error: "Issue not found"]).toString())
.type("application/json")
.build()
}
def result = [
key: issue.key,
summary: issue.summary,
status: issue.status.name,
assignee: issue.assignee?.displayName,
reporter: issue.reporter?.displayName,
created: issue.created.toString(),
updated: issue.updated.toString()
]
return Response.ok(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
HTML ответы
HTML полезен для создания кастомных интерфейсов, виджетов и дашбордов. Можно встроить CSS и JavaScript прямо в ответ:
import javax.ws.rs.core.Response
getDashboard(httpMethod: "GET") { MultivaluedMap queryParams ->
def html = """
Custom Dashboard
Custom Dashboard
Statistics
Total issues: 42
"""
return Response.ok(html)
.type("text/html; charset=utf-8")
.header("Cache-Control", "no-store")
.build()
}
Важно: Для HTML endpoints часто нужно устанавливать заголовки:
Cache-Control: no-store— чтобы браузер не кэшировал динамический контентX-Frame-Options: SAMEORIGIN— для безопасности при встраивании в iframeContent-Type: text/html; charset=utf-8— правильная кодировка
Текстовые ответы
Текстовые ответы полезны для простых данных, CSV, логов или диагностики:
getDiagnostics(httpMethod: "GET") { MultivaluedMap queryParams ->
def diag = queryParams.getFirst("diag")
if (diag == "1") {
def text = """
Issue Key;Status;Days in Status
PROJ-1;Open;5
PROJ-2;In Progress;3
"""
return Response.ok(text)
.type("text/plain; charset=utf-8")
.header("Content-Disposition", "attachment; filename=diagnostics.csv")
.build()
}
return Response.ok("Diagnostics endpoint. Use ?diag=1 for CSV export")
.type("text/plain")
.build()
}
XML ответы
XML может быть полезен для интеграций со старыми системами:
getXmlData(httpMethod: "GET") { MultivaluedMap queryParams ->
def xml = """
success
- Item 1
- Item 2
"""
return Response.ok(xml)
.type("application/xml; charset=utf-8")
.build()
}
Работа с параметрами запроса
В REST endpoints можно получать параметры из разных источников: query string, path parameters, request body, заголовки.
Query параметры
Query параметры передаются через URL: ?param1=value1¶m2=value2
searchIssues(httpMethod: "GET") { MultivaluedMap queryParams ->
def project = queryParams.getFirst("project")
def status = queryParams.getFirst("status")
def limit = queryParams.getFirst("limit") ?: "10"
// Валидация
if (!project) {
return Response.status(400)
.entity(new JsonBuilder([error: "project parameter is required"]).toString())
.type("application/json")
.build()
}
// Построение JQL
def jql = "project = ${project}"
if (status) {
jql += " AND status = '${status}'"
}
// Выполнение поиска
def searchService = ComponentAccessor.getComponent(com.atlassian.jira.bc.issue.search.SearchService)
def user = ComponentAccessor.jiraAuthenticationContext.loggedInUser
def query = searchService.parseQuery(user, jql)
def results = searchService.search(user, query.query,
com.atlassian.jira.web.bean.PagerFilter.getUnlimitedFilter())
def issues = results.results.take(Integer.parseInt(limit)).collect { issue ->
[
key: issue.key,
summary: issue.summary,
status: issue.status.name
]
}
return Response.ok(new JsonBuilder([total: issues.size(), issues: issues]).toPrettyString())
.type("application/json")
.build()
}
Path параметры
Path параметры указываются в пути endpoint: /api/issue/{issueKey}
// Путь: /api/issue/{issueKey}
getIssueByKey(httpMethod: "GET") { MultivaluedMap queryParams, String body,
javax.servlet.http.HttpServletRequest request ->
// Получаем issueKey из пути
def pathInfo = request.pathInfo
def pathParts = pathInfo.split("/")
def issueKey = pathParts[pathParts.length - 1] // последний элемент пути
// Или используем httpMethod.getPathParameter() если настроен путь с {param}
// def issueKey = httpMethod.getPathParameter("issueKey")
def issueManager = ComponentAccessor.getIssueManager()
def issue = issueManager.getIssueObject(issueKey)
if (!issue) {
return Response.status(404)
.entity(new JsonBuilder([error: "Issue not found"]).toString())
.type("application/json")
.build()
}
def result = [
key: issue.key,
summary: issue.summary,
status: issue.status.name
]
return Response.ok(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
Request body (POST/PUT)
Для POST и PUT запросов данные передаются в теле запроса:
createIssue(httpMethod: "POST") { MultivaluedMap queryParams, String body ->
// Парсим JSON из тела запроса
def json = new groovy.json.JsonSlurper().parseText(body)
// Валидация
if (!json.project || !json.summary || !json.issueType) {
return Response.status(400)
.entity(new JsonBuilder([error: "Missing required fields"]).toString())
.type("application/json")
.build()
}
// Создание задачи
def issueManager = ComponentAccessor.getIssueManager()
def issueFactory = ComponentAccessor.getIssueFactory()
def projectManager = ComponentAccessor.getProjectManager()
def userManager = ComponentAccessor.getUserManager()
def project = projectManager.getProjectObjByKey(json.project)
if (!project) {
return Response.status(404)
.entity(new JsonBuilder([error: "Project not found"]).toString())
.type("application/json")
.build()
}
def issue = issueFactory.getIssue()
issue.setProjectObject(project)
issue.setIssueTypeId(json.issueType)
issue.setSummary(json.summary)
issue.setDescription(json.description ?: "")
def currentUser = ComponentAccessor.jiraAuthenticationContext.loggedInUser
def createdIssue = issueManager.createIssueObject(currentUser, issue)
def result = [
key: createdIssue.key,
summary: createdIssue.summary,
status: createdIssue.status.name
]
return Response.status(201)
.entity(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
Заголовки запроса
Заголовки можно получить через HttpServletRequest:
getWithHeaders(httpMethod: "GET") { MultivaluedMap queryParams, String body,
javax.servlet.http.HttpServletRequest request ->
def userAgent = request.getHeader("User-Agent")
def acceptLanguage = request.getHeader("Accept-Language")
def customHeader = request.getHeader("X-Custom-Header")
def result = [
userAgent: userAgent,
acceptLanguage: acceptLanguage,
customHeader: customHeader
]
return Response.ok(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
Аутентификация и авторизация
REST endpoints в ScriptRunner используют стандартную аутентификацию Jira. Можно проверить текущего пользователя и его права:
secureEndpoint(httpMethod: "GET") { MultivaluedMap queryParams ->
def authContext = ComponentAccessor.getJiraAuthenticationContext()
def user = authContext.loggedInUser
// Проверка авторизации
if (!user) {
return Response.status(401)
.entity(new JsonBuilder([error: "Unauthorized"]).toString())
.type("application/json")
.build()
}
// Проверка прав
def groupManager = ComponentAccessor.getGroupManager()
boolean isAdmin = groupManager.isUserInGroup(user, "jira-administrators")
if (!isAdmin) {
return Response.status(403)
.entity(new JsonBuilder([error: "Forbidden: admin access required"]).toString())
.type("application/json")
.build()
}
// Проверка прав на проект
def permissionManager = ComponentAccessor.getPermissionManager()
def project = ComponentAccessor.getProjectManager().getProjectObjByKey("PROJ")
if (!permissionManager.hasPermission(
com.atlassian.jira.security.Permissions.BROWSE_PROJECTS, project, user)) {
return Response.status(403)
.entity(new JsonBuilder([error: "No permission to access project"]).toString())
.type("application/json")
.build()
}
// Основная логика
def result = [
message: "Access granted",
user: user.displayName
]
return Response.ok(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
Практические примеры: что можно выводить
Рассмотрим реальные примеры использования REST endpoints для различных задач.
Пример 1: Агрегация данных и статистика
Endpoint для получения статистики по проекту:
getProjectStats(httpMethod: "GET") { MultivaluedMap queryParams ->
def projectKey = queryParams.getFirst("project")
if (!projectKey) {
return Response.status(400)
.entity(new JsonBuilder([error: "project parameter required"]).toString())
.type("application/json")
.build()
}
def searchService = ComponentAccessor.getComponent(com.atlassian.jira.bc.issue.search.SearchService)
def user = ComponentAccessor.jiraAuthenticationContext.loggedInUser
// Статистика по статусам
def statuses = ["Open", "In Progress", "Done", "Closed"]
def statusStats = [:]
statuses.each { status ->
def jql = "project = ${projectKey} AND status = '${status}'"
def query = searchService.parseQuery(user, jql)
def results = searchService.search(user, query.query,
com.atlassian.jira.web.bean.PagerFilter.getUnlimitedFilter())
statusStats[status] = results.total
}
// Статистика по приоритетам
def priorities = ["Highest", "High", "Medium", "Low"]
def priorityStats = [:]
priorities.each { priority ->
def jql = "project = ${projectKey} AND priority = '${priority}'"
def query = searchService.parseQuery(user, jql)
def results = searchService.search(user, query.query,
com.atlassian.jira.web.bean.PagerFilter.getUnlimitedFilter())
priorityStats[priority] = results.total
}
def result = [
project: projectKey,
statistics: [
byStatus: statusStats,
byPriority: priorityStats,
total: statusStats.values().sum()
]
]
return Response.ok(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
Пример 2: HTML-виджет с данными из Jira
Endpoint, возвращающий HTML-виджет с данными:
getWidget(httpMethod: "GET") { MultivaluedMap queryParams ->
def user = ComponentAccessor.jiraAuthenticationContext.loggedInUser
if (!user) {
return Response.status(401)
.entity("Unauthorized")
.type("text/html")
.build()
}
// Получаем задачи пользователя
def searchService = ComponentAccessor.getComponent(com.atlassian.jira.bc.issue.search.SearchService)
def jql = "assignee = currentUser() AND status != Done"
def query = searchService.parseQuery(user, jql)
def results = searchService.search(user, query.query,
com.atlassian.jira.web.bean.PagerFilter.getUnlimitedFilter())
def issues = results.results.collect { issue ->
[
key: issue.key,
summary: issue.summary,
status: issue.status.name,
priority: issue.priority?.name ?: "None"
]
}
// Генерируем HTML
def html = new StringBuilder()
html.append("")
html.append("My Tasks Widget ")
html.append("")
html.append("")
return Response.ok(html.toString())
.type("text/html; charset=utf-8")
.header("Cache-Control", "no-store")
.build()
}
Пример 3: Интеграция с внешними системами
Endpoint, который получает данные из внешней системы и объединяет с данными Jira:
getCombinedData(httpMethod: "GET") { MultivaluedMap queryParams ->
def issueKey = queryParams.getFirst("issueKey")
if (!issueKey) {
return Response.status(400)
.entity(new JsonBuilder([error: "issueKey required"]).toString())
.type("application/json")
.build()
}
// Получаем данные из Jira
def issueManager = ComponentAccessor.getIssueManager()
def issue = issueManager.getIssueObject(issueKey)
if (!issue) {
return Response.status(404)
.entity(new JsonBuilder([error: "Issue not found"]).toString())
.type("application/json")
.build()
}
def jiraData = [
key: issue.key,
summary: issue.summary,
status: issue.status.name
]
// Получаем данные из внешней системы
def externalData = [:]
try {
def externalApiUrl = "https://external-api.example.com/issues/${issueKey}"
def client = java.net.http.HttpClient.newHttpClient()
def request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(externalApiUrl))
.GET()
.build()
def response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
externalData = new groovy.json.JsonSlurper().parseText(response.body())
}
} catch (Exception e) {
log.warn("Failed to fetch external data: ${e.message}")
}
// Объединяем данные
def result = [
jira: jiraData,
external: externalData,
combined: [
key: issueKey,
summary: jiraData.summary,
externalStatus: externalData.status ?: "unknown"
]
]
return Response.ok(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
Пример 4: Календарь и временные данные
Endpoint для отображения календаря с данными из Jira:
getCalendar(httpMethod: "GET") { MultivaluedMap queryParams ->
def year = queryParams.getFirst("year") ?: String.valueOf(java.time.LocalDate.now().getYear())
def month = queryParams.getFirst("month") ?: String.valueOf(java.time.LocalDate.now().getMonthValue())
def searchService = ComponentAccessor.getComponent(com.atlassian.jira.bc.issue.search.SearchService)
def user = ComponentAccessor.jiraAuthenticationContext.loggedInUser
// Поиск задач с датами в указанном месяце
def startDate = "${year}-${month.padLeft(2, '0')}-01"
def endDate = "${year}-${month.padLeft(2, '0')}-31"
def jql = """
assignee = currentUser()
AND "Due Date" >= ${startDate}
AND "Due Date" <= ${endDate}
"""
def query = searchService.parseQuery(user, jql)
def results = searchService.search(user, query.query,
com.atlassian.jira.web.bean.PagerFilter.getUnlimitedFilter())
// Группируем по датам
def byDate = [:]
results.results.each { issue ->
def dueDate = issue.dueDate
if (dueDate) {
def dateKey = new java.text.SimpleDateFormat("yyyy-MM-dd").format(dueDate)
if (!byDate[dateKey]) {
byDate[dateKey] = []
}
byDate[dateKey].add([
key: issue.key,
summary: issue.summary
])
}
}
def result = [
year: year,
month: month,
events: byDate
]
return Response.ok(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
}
Обработка ошибок
Правильная обработка ошибок критична для надёжности API:
robustEndpoint(httpMethod: "GET") { MultivaluedMap queryParams ->
try {
// Основная логика
def result = performComplexOperation()
return Response.ok(new JsonBuilder(result).toPrettyString())
.type("application/json")
.build()
} catch (IllegalArgumentException e) {
// Ошибка валидации
log.warn("Validation error: ${e.message}", e)
return Response.status(400)
.entity(new JsonBuilder([
error: "Bad Request",
message: e.message
]).toString())
.type("application/json")
.build()
} catch (com.atlassian.jira.exception.IssueNotFoundException e) {
// Задача не найдена
log.warn("Issue not found: ${e.message}", e)
return Response.status(404)
.entity(new JsonBuilder([
error: "Not Found",
message: "Issue not found"
]).toString())
.type("application/json")
.build()
} catch (Exception e) {
// Общая ошибка
log.error("Unexpected error: ${e.message}", e)
return Response.status(500)
.entity(new JsonBuilder([
error: "Internal Server Error",
message: "An unexpected error occurred"
]).toString())
.type("application/json")
.build()
}
}
Кэширование ответов
Для тяжёлых операций можно использовать кэширование:
import com.atlassian.cache.Cache
import com.atlassian.cache.CacheManager
getCachedData(httpMethod: "GET") { MultivaluedMap queryParams ->
def cacheManager = ComponentAccessor.getComponent(CacheManager)
def cache = cacheManager.getCache("rest-api-cache", String.class, String.class)
def cacheKey = "stats-${new Date().clearTime()}"
def cachedResult = cache.get(cacheKey)
if (cachedResult) {
return Response.ok(cachedResult)
.type("application/json")
.header("X-Cache", "HIT")
.build()
}
// Выполняем тяжёлые вычисления
def result = computeStatistics()
def json = new JsonBuilder(result).toPrettyString()
// Кэшируем на 1 час
cache.put(cacheKey, json)
return Response.ok(json)
.type("application/json")
.header("X-Cache", "MISS")
.build()
}
Лучшие практики
При создании REST endpoints следуйте этим рекомендациям:
1. Валидация входных данных
Всегда проверяйте входные параметры и возвращайте понятные ошибки:
if (!projectKey) {
return Response.status(400)
.entity(new JsonBuilder([
error: "Bad Request",
message: "projectKey parameter is required"
]).toString())
.type("application/json")
.build()
}
2. Правильные HTTP статус-коды
200 OK— успешный запрос201 Created— ресурс создан400 Bad Request— ошибка валидации401 Unauthorized— требуется авторизация403 Forbidden— нет прав доступа404 Not Found— ресурс не найден500 Internal Server Error— внутренняя ошибка
3. Производительность
Оптимизируйте запросы к базе данных, используйте кэширование для тяжёлых операций, ограничивайте количество возвращаемых данных:
// Ограничение количества результатов
def limit = Integer.parseInt(queryParams.getFirst("limit") ?: "100")
def issues = results.results.take(limit)
// Использование кэша
def cached = cache.get(cacheKey)
if (cached) return Response.ok(cached).build()
4. Безопасность
- Проверяйте права доступа пользователя
- Валидируйте и санитизируйте входные данные
- Используйте параметризованные запросы (JQL безопасен, но проверяйте входные данные)
- Не возвращайте чувствительные данные без проверки прав
5. Документация
Документируйте ваши endpoints: какие параметры принимают, что возвращают, примеры использования.
Типичные ошибки
Вот ошибки, которые часто допускают при создании REST endpoints:
Ошибка 1: Отсутствие обработки null
// ❌ Плохо
def issueKey = queryParams.getFirst("issueKey")
def issue = issueManager.getIssueObject(issueKey) // может быть null
// ✅ Хорошо
def issueKey = queryParams.getFirst("issueKey")
if (!issueKey) {
return Response.status(400).entity(...).build()
}
def issue = issueManager.getIssueObject(issueKey)
if (!issue) {
return Response.status(404).entity(...).build()
}
Ошибка 2: Игнорирование производительности
// ❌ Плохо - загрузка всех задач без ограничений
def issues = searchService.search(user, query.query,
PagerFilter.getUnlimitedFilter()).results
// ✅ Хорошо - ограничение количества
def pager = new PagerFilter(100) // максимум 100 задач
def issues = searchService.search(user, query.query, pager).results
Ошибка 3: Неправильные типы ответов
// ❌ Плохо - JSON без правильного Content-Type
return Response.ok(jsonString).build()
// ✅ Хорошо - правильный Content-Type
return Response.ok(jsonString)
.type("application/json")
.build()
Выводы
REST endpoints в ScriptRunner — мощный инструмент для создания кастомных API без разработки плагинов. Они позволяют возвращать JSON, HTML, XML, текст, работать с параметрами запроса, обрабатывать ошибки, интегрироваться с внешними системами и создавать кастомные интерфейсы.
Ключевые моменты:
- Используйте правильные HTTP методы и статус-коды
- Валидируйте входные данные
- Правильно обрабатывайте ошибки
- Оптимизируйте производительность
- Проверяйте права доступа
- Документируйте ваши endpoints
Если нужна помощь с созданием REST endpoints или интеграциями — свяжитесь со мной.