← Назад к статьям

REST endpoints в ScriptRunner: детальное руководство

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:

  1. Перейдите в Administration → ScriptRunner → REST → Scripted REST Endpoints
  2. Нажмите Create endpoint
  3. Укажите имя endpoint (например, myCustomApi)
  4. Выберите HTTP метод (GET, POST, PUT, DELETE)
  5. Укажите путь (например, /api/data или /api/data/{id})
  6. Напишите 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 — для безопасности при встраивании в iframe
  • Content-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("
") html.append("

My Tasks (${issues.size()})

") issues.each { issue -> html.append("
") html.append("${issue.key} - ${issue.summary}
") html.append("${issue.status}") 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 или интеграциями — свяжитесь со мной.