Merge pull request #23465 from opf/xwiki-search

Implement real search_pages for XWiki
This commit is contained in:
Jan Sandbrink
2026-06-03 16:09:29 +02:00
committed by GitHub
11 changed files with 671 additions and 48 deletions
@@ -65,6 +65,10 @@ module Wikis
Adapters::Registry["#{self.class.registry_prefix}.#{registry_path}"].new(model: self, **init_options)
end
def inspect
"#<#{self.class.name} id: #{id} name: #{name}>"
end
private
def generate_universal_identifier
@@ -0,0 +1,80 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Wikis
module Adapters
module Providers
module XWiki
module Queries
module Concerns
module XWikiQuery
ACCEPT_HEADERS = { "Accept" => "application/json" }.freeze
def authenticated(auth_strategy)
Adapters::Authentication[auth_strategy].call do |http|
yield http.with(headers: ACCEPT_HEADERS)
end
end
def rest_url(path, query: nil)
# TODO: we might be able to extract a common URL formatting helper from Storages::UrlBuilder
url = "#{provider.url.chomp('/')}/rest/#{path.delete_prefix('/')}"
return url if query.nil?
"#{url}?#{query.to_query}"
end
def handle_response(response)
return failure(code: :connection_error) if response.is_a?(HTTPX::ErrorResponse)
case response
in { status: 200..299 }
begin
json = response.json
rescue MultiJson::ParseError
return failure(code: :invalid_response)
end
yield json
in { status: 401 | 403 }
failure(code: :unauthorized)
in { status: 404 }
failure(code: :not_found)
else
failure(code: :request_failed)
end
end
end
end
end
end
end
end
end
@@ -34,51 +34,25 @@ module Wikis
module XWiki
module Queries
class PageInfo < BaseQuery
ACCEPT_HEADERS = { "Accept" => "application/json" }.freeze
include Concerns::XWikiQuery
def call(input_data:, auth_strategy:)
ref = PageReference.parse(input_data.identifier)
return failure(code: :not_found) unless ref
url = "#{provider.url.chomp('/')}/rest#{ref.rest_path}"
Adapters::Authentication[auth_strategy].call do |http|
handle_response(
http.with(headers: ACCEPT_HEADERS).get(url),
identifier: input_data.identifier
)
authenticated(auth_strategy) do |http|
handle_response(http.get(rest_url(ref.rest_path))) do |data|
success(
Results::PageInfo.new(
identifier: input_data.identifier,
title: data["title"],
href: data["xwikiAbsoluteUrl"],
provider:
)
)
end
end
end
private
def handle_response(response, identifier:)
return failure(code: :connection_error) if response.is_a?(HTTPX::ErrorResponse)
case response
in { status: 200..299 }
handle_success_response(response, identifier:)
in { status: 401 | 403 }
failure(code: :unauthorized)
in { status: 404 }
failure(code: :not_found)
else
failure(code: :request_failed)
end
end
def handle_success_response(response, identifier:)
data = response.json
success(
Results::PageInfo.new(
identifier:,
title: data["title"],
href: data["xwikiAbsoluteUrl"],
provider:
)
)
rescue MultiJson::ParseError
failure(code: :invalid_response)
end
end
end
end
@@ -34,16 +34,33 @@ module Wikis
module XWiki
module Queries
class SearchPages < BaseQuery
def call(input_data:, **)
# TODO: use real API endpoints once available
include Concerns::XWikiQuery
titles = [
"#{input_data.query} makes XWiki special",
"API documentation of #{input_data.query}",
"A brief introduction on configuring your own #{input_data.query}."
]
MAXIMUM_RESULTS = 50
success(titles.map { Results::PageInfo.new(identifier: "1338", title: it, href: "#", provider:) })
def call(input_data:, auth_strategy:)
query = { q: "\"#{escape_quotes input_data.query}\"", number: MAXIMUM_RESULTS }
authenticated(auth_strategy) do |http|
handle_response(http.get(rest_url("wikis/query", query:))) do |json|
success(
json.fetch("searchResults")
.uniq { |r| r.fetch("id") }
.map do |r|
result = page_info(identifier: r.fetch("id"), auth_strategy:)
return result if result.failure?
result.value!
end
)
end
end
end
private
def escape_quotes(string)
string.gsub("\\", "\\\\").gsub('"', '\"')
end
end
end
@@ -43,9 +43,16 @@ FactoryBot.define do
factory :xwiki_provider, class: "Wikis::XWikiProvider", parent: :wiki_provider do
url { "https://xwiki.example.com/" }
transient do
oauth_client_id { "openproject-#{SecureRandom.hex(8)}" }
oauth_client_secret { SecureRandom.alphanumeric(20) }
end
trait :with_oauth_client do
after(:create) do |provider, _|
create(:oauth_client, integration: provider)
after(:create) do |provider, evaluator|
create :oauth_client, integration: provider,
client_id: evaluator.oauth_client_id,
client_secret: evaluator.oauth_client_secret
end
end
@@ -65,6 +72,15 @@ FactoryBot.define do
end
end
trait :for_local_connection do
with_connected_user
url { "https://xwiki.local" }
connected_user_token { ENV.fetch("XWIKI_LOCAL_OAUTH_CLIENT_ACCESS_TOKEN", "TOKEN_NOT_CONFIGURED") }
oauth_client_id { ENV.fetch("XWIKI_LOCAL_OAUTH_CLIENT_ID", "CLIENT_ID_NOT_CONFIGURED") }
oauth_client_secret { ENV.fetch("XWIKI_LOCAL_OAUTH_CLIENT_SECRET", "CLIENT_SECRET_NOT_CONFIGURED") }
end
trait :with_oauth_configured do
after(:create) do |provider, _evaluator|
create(:oauth_client, integration: provider)
@@ -0,0 +1,111 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
RSpec.describe Wikis::Adapters::Providers::XWiki::Queries::SearchPages, :webmock do
subject { described_class.new(model: provider).call(input_data:, auth_strategy:) }
let(:provider) { create(:xwiki_provider, :for_local_connection, connected_user: user) }
let(:input_data) { Wikis::Adapters::Input::SearchPages.build(query:).value! }
let(:auth_strategy) { provider.auth_strategy_for(user).value! }
let(:user) { create(:user) }
# Before recording VCR cassettes of this, ensure pages with the following titles exist in XWiki:
# * Test Page for RSpec
# * "Quoted" pages can be tricky
context "when there are exactly matching pages", vcr: "xwiki/query_exact_match" do
let(:query) { "Test Page for RSpec" }
it { is_expected.to be_success }
it "returns matching pages" do
expect(subject.value!).not_to be_empty
expect(subject.value!.first.title).to eq("Test Page for RSpec")
end
it "returns no other random results" do
expect(subject.value!.count).to eq(1)
end
end
context "when there are partially matching pages", vcr: "xwiki/query_partial_match" do
let(:query) { "for RSpec" }
it { is_expected.to be_success }
it "returns matching pages" do
expect(subject.value!).not_to be_empty
expect(subject.value!.first.title).to eq("Test Page for RSpec")
end
it "returns no other random results" do
expect(subject.value!.count).to eq(1)
end
end
context "when the searched page contains quotes", vcr: "xwiki/query_quoted_match" do
let(:query) { '"Quoted" pages can be tricky' }
it { is_expected.to be_success }
it "returns matching pages" do
expect(subject.value!).not_to be_empty
expect(subject.value!.first.title).to eq('"Quoted" pages can be tricky')
end
it "returns no other random results" do
expect(subject.value!.count).to eq(1)
end
context "and the query omits the quotes", vcr: "xwiki/query_unquoted_match" do
let(:query) { "Quoted pages can be tricky" }
it { is_expected.to be_success }
it "returns matching pages" do
expect(subject.value!).not_to be_empty
expect(subject.value!.first.title).to eq('"Quoted" pages can be tricky')
end
end
end
context "when there are no matching pages", vcr: "xwiki/query_no_match" do
let(:query) { "A page that does not exist" }
it { is_expected.to be_success }
it "returns an empty result" do
expect(subject.value!).to eq([])
end
end
end