Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ module Input
class PageInfoContract < DryApplicationContract
params do
required(:identifier).filled(:string)
optional(:access_token).maybe(:string)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I read the headline of the PR, I suspected that I would find exactly this in here 🙈

Is the current action plan to temporarily expect this optional parameter and work on a proper implementation of authorization strategies as the immediate next thing?

The pain that I have with reviewing this is that there will be a lot of code in the wrong place right now and we need to be aligned that this code is planned to be moved to the right place.

The alternative would be to leave this PR in draft state and tackle authentication first, then return here to finish the querying off properly.

CC @mereghost @Kharonus

end
end
end
Expand Down
6 changes: 3 additions & 3 deletions modules/wikis/app/models/wikis/adapters/input/page_info.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@
#++

module Wikis::Adapters::Input
PageInfo = Data.define(:identifier) do
PageInfo = Data.define(:identifier, :access_token) do
private_class_method :new

def self.build(identifier:, contract: PageInfoContract.new)
contract.call(identifier:).to_monad.fmap { new(**it.to_h) }
def self.build(identifier:, access_token: nil, contract: PageInfoContract.new)
contract.call(identifier:, access_token:).to_monad.fmap { new(**it.to_h) }
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@
#++

module Wikis::Adapters::Input
UserQuery = Data.define(:access_token)
User = Data.define(:access_token)
end
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@
#++

module Wikis::Adapters::Results
PageInfo = Data.define(:identifier, :provider, :title, :href)
PageInfo = Data.define(:identifier, :title, :href)
end
1 change: 1 addition & 0 deletions modules/wikis/app/models/wikis/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class Provider < ApplicationRecord

def to_s = self.class.registry_prefix
def user_connected?(_user) = raise SubclassResponsibilityError
def user_access_token(_user) = nil

class << self
def registry_prefix = raise SubclassResponsibilityError
Expand Down
6 changes: 5 additions & 1 deletion modules/wikis/app/models/wikis/xwiki_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,12 @@ def user_connected?(user)
OAuthClientToken.for_user_and_client(user, oauth_client).exists?
end

def user_access_token(user)
Copy link
Copy Markdown
Contributor

@NobodysNightmare NobodysNightmare May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To give one example why I am skeptical of the approach of merging this PR first and then taking care of authentication: This is a method that's now added to the public interface of every provider.

In my mind, this method has to be removed in a subsequent PR and it's existence is only temporary. But I have no clue whether this is how you think about this method. You might want to keep it and then one of two things would happen at a later point:

  • I forget about this method and don't even mention it anymore; I will be very sad once I rediscover it
  • I don't forget about this method and ask in the 2nd PR why it's not gone; You will be very sad because you thought we were aligned on the path forward and I already approved this method in PR 1

OAuthClientToken.find_by(user:, oauth_client:)&.access_token
end

def extract_origin_user_id(token)
resolve("queries.user").call(Wikis::Adapters::Input::UserQuery.new(access_token: token.access_token))
resolve("queries.user").call(Wikis::Adapters::Input::User.new(access_token: token.access_token))
end

def authenticate_via_two_way_oauth2?
Expand Down
4 changes: 4 additions & 0 deletions modules/wikis/app/services/wikis/adapters/base_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def success(result)
Success(result)
end

def bearer_http(token)
token.present? ? OpenProject.httpx.bearer_auth(token) : OpenProject.httpx
end

def failure(code:)
Failure(Results::Error.new(source: self.class, code:))
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ def call(input_data)
success(
Results::PageInfo.new(
identifier: input_data.identifier,
provider:,
title: wiki_page.title,
href: url_for(only_path: true,
controller: "/wiki",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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
# Represents a parsed XWiki stable page identifier in canonical document reference format:
# "wikiName:Space1.Space2.PageName" — e.g. "xwiki:Main.WebHome"
# Maps to the REST API path: /wikis/{wiki}/spaces/{s1}/spaces/{s2}/pages/{page}
PageReference = Data.define(:wiki, :spaces, :page) do
def self.parse(identifier)
wiki, page_path = identifier.split(":", 2)
return nil if page_path.blank?

parts = page_path.split(".")
page = parts.pop
return nil if parts.empty?

new(wiki:, spaces: parts, page:)
end

def rest_path
spaces_path = spaces.map { "/spaces/#{CGI.escapeURIComponent(it)}" }.join
"/wikis/#{CGI.escapeURIComponent(wiki)}#{spaces_path}/pages/#{CGI.escapeURIComponent(page)}"
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,47 @@ module Providers
module XWiki
module Queries
class PageInfo < BaseQuery
JSON_ACCEPT_HEADERS = { "Accept" => "application/json" }.freeze

def call(input_data)
titles = [
"What makes XWiki special?",
"API documentation",
"A brief introduction on configuring your own XWiki instance and connect it to OpenProject.",
"Security considerations for API design",
"Syntax overview",
"Getting help",
"Enterprise support"
]
title = titles[Random.new(input_data.identifier.hash).rand(titles.size)]
ref = PageReference.parse(input_data.identifier)
return failure(code: :not_found) unless ref

url = "#{provider.url.chomp('/')}/rest#{ref.rest_path}"
handle_response(
bearer_http(input_data.access_token).with(headers: JSON_ACCEPT_HEADERS).get(url),
identifier: input_data.identifier
)
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 = JSON.parse(response.body.to_s)
success(
Results::PageInfo.new(
identifier: input_data.identifier,
provider:,
title:,
href: "#"
identifier:,
title: data["title"],
href: data["xwikiAbsoluteUrl"]
)
)
rescue JSON::ParserError
failure(code: :request_failed)
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ def call(input_data)
results = []

if input_data.linkable.id % 2 == 0
results << Success(Results::PageInfo.new(identifier: "1337", provider:, title: title.sample, href: "#"))
results << Success(Results::PageInfo.new(identifier: "1338", provider:, title: title.sample, href: "#"))
results << Success(Results::PageInfo.new(identifier: "1337", title: title.sample, href: "#"))
results << Success(Results::PageInfo.new(identifier: "1338", title: title.sample, href: "#"))
end

success(results)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ def call(input_data) # rubocop:disable Metrics/AbcSize
results = []

if input_data.linkable.id % 2 == 1
results << Success(Results::PageInfo.new(identifier: "1337", provider:, title: title.sample, href: "#"))
results << Success(Results::PageInfo.new(identifier: "1338", provider:, title: title.sample, href: "#"))
results << Success(Results::PageInfo.new(identifier: "1338", provider:, title: title.sample, href: "#"))
results << Success(Results::PageInfo.new(identifier: "1337", title: title.sample, href: "#"))
results << Success(Results::PageInfo.new(identifier: "1338", title: title.sample, href: "#"))
results << Success(Results::PageInfo.new(identifier: "1338", title: title.sample, href: "#"))
end

success(results)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ module Adapters
module Providers
module XWiki
module Queries
class UserQuery < BaseQuery
class User < BaseQuery
def call(input_data)
url = "#{provider.url.chomp('/')}/rest/"
handle_response(OpenProject.httpx.bearer_auth(input_data.access_token).get(url))
handle_response(bearer_http(input_data.access_token).get(url))
end

private
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ module XWiki
end

namespace("queries") do
register(:user, Queries::UserQuery)
register(:user, Queries::User)
register(:page_info, Queries::PageInfo)
register(:referencing_pages, Queries::ReferencingPages)
register(:relation_page_links, Queries::RelationPageLinks)
Expand Down
3 changes: 2 additions & 1 deletion modules/wikis/app/services/wikis/page_link_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ def referencing_wiki_page_infos_for(linkable:)
private

def page_info(provider:, identifier:)
Adapters::Input::PageInfo.build(identifier:).bind { provider.resolve("queries.page_info").call(it) }
access_token = provider.user_access_token(User.current)
Adapters::Input::PageInfo.build(identifier:, access_token:).bind { provider.resolve("queries.page_info").call(it) }
end

def page_title_service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@
let(:page_info) do
Wikis::Adapters::Results::PageInfo.new(
identifier: "MyPage",
provider:,
title: "My Wiki Page",
href: "https://wiki.example.com/MyPage"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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"
require_module_spec_helper

RSpec.describe Wikis::Adapters::Providers::XWiki::PageReference do
describe ".parse" do
subject { described_class.parse(identifier) }

context "with a standard identifier" do
let(:identifier) { "xwiki:Main.WebHome" }

it { is_expected.to have_attributes(wiki: "xwiki", spaces: ["Main"], page: "WebHome") }
end

context "with a nested space identifier" do
let(:identifier) { "xwiki:MySpace.SubSpace.PageName" }

it { is_expected.to have_attributes(wiki: "xwiki", spaces: %w[MySpace SubSpace], page: "PageName") }
end

context "without a colon separator" do
let(:identifier) { "Main.WebHome" }

it { is_expected.to be_nil }
end

context "with a blank page path" do
let(:identifier) { "xwiki:" }

it { is_expected.to be_nil }
end

context "without a space segment" do
let(:identifier) { "xwiki:WebHome" }

it { is_expected.to be_nil }
end
end

describe "#rest_path" do
subject { described_class.parse(identifier).rest_path }

context "with a single-space identifier" do
let(:identifier) { "xwiki:Main.WebHome" }

it { is_expected.to eq("/wikis/xwiki/spaces/Main/pages/WebHome") }
end

context "with a nested-space identifier" do
let(:identifier) { "xwiki:MySpace.SubSpace.PageName" }

it { is_expected.to eq("/wikis/xwiki/spaces/MySpace/spaces/SubSpace/pages/PageName") }
end

context "with special characters in segments" do
let(:identifier) { "xwiki:My Space.My Page" }

it { is_expected.to eq("/wikis/xwiki/spaces/My%20Space/pages/My%20Page") }
end
end
end
Loading
Loading