diff --git a/.rubocop.yml b/.rubocop.yml index dd7673317..ae85abdef 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,16 +1,27 @@ --- +require: rubocop-graphql inherit_from: - https://raspberrypifoundation.github.io/digital-engineering/configs/rubocop-base.yml - https://raspberrypifoundation.github.io/digital-engineering/configs/rubocop-rails.yml - https://raspberrypifoundation.github.io/digital-engineering/configs/rubocop-rspec.yml - .rubocop_todo.yml -# RPF Digital Products house styles - - # Include your pre-existing rubocop_todo.yaml here, if you have one. - # - .rubocop_todo.yml - # Allow the Exclude arrays to be merged. inherit_mode: merge: - Exclude + +GraphQL/ObjectDescription: + Exclude: + - app/graphql/types/mutation_type.rb + - app/graphql/types/node_type.rb + - app/graphql/types/query_type.rb + +RSpec/NestedGroups: + Max: 4 + +RSpec/DescribeClass: + Exclude: + - "spec/graphql/queries/**" + - "spec/graphql/mutations/**" + diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 77055fa8c..86e143de7 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,19 +1,12 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2022-02-10 15:43:19 UTC using RuboCop version 1.23.0. +# on 2023-02-20 08:27:43 UTC using RuboCop version 1.39.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 4 +# Offense count: 2 Lint/UselessAssignment: Exclude: - 'lib/tasks/projects.rake' - -# Offense count: 4 -# Configuration parameters: ForbiddenDelimiters. -# ForbiddenDelimiters: (?-mix:(^|\s)(EO[A-Z]{1}|END)(\s|$)) -Naming/HeredocDelimiterNaming: - Exclude: - - 'lib/tasks/projects.rake' diff --git a/Gemfile b/Gemfile index 24e93f47c..05dfa3c71 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,9 @@ gem 'bootsnap', require: false gem 'cancancan', '~> 3.3' gem 'faraday' gem 'github_webhook', '~> 1.4' +gem 'globalid' +gem 'good_job', '~> 3.12' +gem 'graphql' gem 'importmap-rails' gem 'jbuilder' gem 'kaminari' @@ -28,6 +31,7 @@ group :development, :test do gem 'rspec_junit_formatter' gem 'rspec-rails' gem 'rubocop', require: false + gem 'rubocop-graphql', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false gem 'simplecov', require: false @@ -38,5 +42,3 @@ group :test do gem 'shoulda-matchers', '~> 5.0' gem 'webmock' end - -gem 'good_job', '~> 3.12' diff --git a/Gemfile.lock b/Gemfile.lock index c5cca8ce3..9f67301ef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -133,6 +133,7 @@ GEM railties (>= 6.0.0) thor (>= 0.14.1) webrick (>= 1.3) + graphql (2.0.17) hashdiff (1.0.1) i18n (1.12.0) concurrent-ruby (~> 1.0) @@ -264,6 +265,8 @@ GEM unicode-display_width (>= 1.4.0, < 3.0) rubocop-ast (1.23.0) parser (>= 3.1.1.0) + rubocop-graphql (0.19.0) + rubocop (>= 0.87, < 2) rubocop-rails (2.17.3) activesupport (>= 4.2.0) rack (>= 1.1) @@ -314,7 +317,9 @@ DEPENDENCIES faker faraday github_webhook (~> 1.4) + globalid good_job (~> 3.12) + graphql importmap-rails jbuilder kaminari @@ -327,6 +332,7 @@ DEPENDENCIES rspec-rails rspec_junit_formatter rubocop + rubocop-graphql rubocop-rails rubocop-rspec sentry-rails (~> 5.5.0) diff --git a/app/controllers/concerns/authentication_concern.rb b/app/controllers/concerns/authentication_concern.rb new file mode 100644 index 000000000..f20549cb4 --- /dev/null +++ b/app/controllers/concerns/authentication_concern.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'hydra_admin_api' + +module AuthenticationConcern + extend ActiveSupport::Concern + + def current_user_id + return @current_user_id if @current_user_id + + token = request.headers['Authorization'] + return nil unless token + + @current_user_id = HydraAdminApi.fetch_oauth_user_id(token:) + end +end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 000000000..dad3b9090 --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class GraphqlController < ApplicationController + include AuthenticationConcern + include ActiveStorage::SetCurrent if Rails.env.development? + + skip_before_action :verify_authenticity_token + + # If accessing from outside this domain, nullify the session + # This allows for outside API access while preventing CSRF attacks, + # but you'll have to authenticate your user separately + # protect_from_forgery with: :null_session + + def execute + result = EditorApiSchema.execute(query, variables:, context:, operation_name:) + render json: result + rescue StandardError => e + raise e unless Rails.env.development? + + handle_error_in_development(e) + end + + private + + def query + params[:query] + end + + def operation_name + params[:operationName] + end + + def context + @context ||= { current_user_id:, current_ability: Ability.new(current_user_id) } + end + + # Handle variables in form data, JSON body, or a blank value + def variables + variables_param = params[:variables] + + case params[:variables] + when String + if variables_param.present? + JSON.parse(variables_param) || {} + else + {} + end + when Hash + variables_param + when ActionController::Parameters + variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables. + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{variables_param}" + end + end + + def handle_error_in_development(error) + logger.error error.message + logger.error error.backtrace.join("\n") + + render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} }, + status: :internal_server_error + end +end diff --git a/app/graphql/editor_api_schema.rb b/app/graphql/editor_api_schema.rb new file mode 100644 index 000000000..13c1dabbc --- /dev/null +++ b/app/graphql/editor_api_schema.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class EditorApiSchema < GraphQL::Schema + mutation Types::MutationType + query Types::QueryType + + # For batch-loading (see https://graphql-ruby.org/dataloader/overview.html) + use GraphQL::Dataloader + + # GraphQL-Ruby calls this when something goes wrong while running a query: + + # Union and Interface Resolution + def self.resolve_type(_abstract_type, obj, _ctx) + case obj + when Project + Types::ProjectType + when Component + Types::ComponentType + else + raise("Unexpected object: #{obj}") + end + end + + def self.unauthorized_object(error) + # Add a top-level error to the response instead of returning nil: + raise GraphQL::ExecutionError, "An object of type #{error.type.graphql_name} was hidden due to permissions" + end + + def self.unauthorized_field(error) + # Add a top-level error to the response instead of returning nil: + raise GraphQL::ExecutionError, + "The field #{error.field.graphql_name} on " \ + "an object of type #{error.type.graphql_name} was hidden due to permissions" + end + + # Stop validating when it encounters this many errors: + validate_max_errors 100 + default_max_page_size 10 + + # Relay-style Object Identification: + + # Return a string UUID for `object` + def self.id_from_object(object, _type_definition, _query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + object.to_gid_param + end + + # Given a string UUID, find the object + def self.object_from_id(global_id, _query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + GlobalID.find(global_id) + end +end diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb new file mode 100644 index 000000000..0ff6c4eec --- /dev/null +++ b/app/graphql/mutations/base_mutation.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Mutations + class BaseMutation < GraphQL::Schema::RelayClassicMutation + argument_class Types::BaseArgument + field_class Types::BaseField + input_object_class Types::BaseInputObject + object_class Types::BaseObject + end +end diff --git a/app/graphql/mutations/create_project.rb b/app/graphql/mutations/create_project.rb new file mode 100644 index 000000000..7f45ed0d5 --- /dev/null +++ b/app/graphql/mutations/create_project.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + class CreateProject < BaseMutation + description 'A mutation to create a new project' + + field :project, Types::ProjectType, description: 'The project that has been created' + + # rubocop:disable GraphQL/ExtractInputType + argument :components, [Types::ComponentInputType], required: false, description: 'Any project components' + argument :name, String, required: true, description: 'The name of the project' + argument :project_type, String, required: true, description: 'The type of project, e.g. python, html' + # rubocop:enable GraphQL/ExtractInputType + + def resolve(**input) + project_hash = input.merge(user_id: context[:current_user_id], + components: input[:components]&.map(&:to_h)) + + response = Project::Create.call(project_hash:) + raise GraphQL::ExecutionError, response[:error] unless response.success? + + { project: response[:project] } + end + + def ready?(**_args) + return true if context[:current_ability]&.can?(:create, Project, user_id: context[:current_user_id]) + + raise GraphQL::ExecutionError, 'You are not permitted to create a project' + end + end +end diff --git a/app/graphql/types/.keep b/app/graphql/types/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb new file mode 100644 index 000000000..2e2278c5a --- /dev/null +++ b/app/graphql/types/base_argument.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseArgument < GraphQL::Schema::Argument + end +end diff --git a/app/graphql/types/base_connection.rb b/app/graphql/types/base_connection.rb new file mode 100644 index 000000000..ea82fba89 --- /dev/null +++ b/app/graphql/types/base_connection.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class BaseConnection < Types::BaseObject + # add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides + include GraphQL::Types::Relay::ConnectionBehaviors + + field :total_count, Int, null: false, description: 'Total number of nodes available' + + def total_count + object.items.size + end + end +end diff --git a/app/graphql/types/base_edge.rb b/app/graphql/types/base_edge.rb new file mode 100644 index 000000000..e0d2f79c0 --- /dev/null +++ b/app/graphql/types/base_edge.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseEdge < Types::BaseObject + # add `node` and `cursor` fields, as well as `node_type(...)` override + include GraphQL::Types::Relay::EdgeBehaviors + end +end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb new file mode 100644 index 000000000..cf43fea45 --- /dev/null +++ b/app/graphql/types/base_enum.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseEnum < GraphQL::Schema::Enum + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb new file mode 100644 index 000000000..611eb0560 --- /dev/null +++ b/app/graphql/types/base_field.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class BaseField < GraphQL::Schema::Field + argument_class Types::BaseArgument + end +end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb new file mode 100644 index 000000000..279511321 --- /dev/null +++ b/app/graphql/types/base_input_object.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class BaseInputObject < GraphQL::Schema::InputObject + argument_class Types::BaseArgument + end +end diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb new file mode 100644 index 000000000..188993871 --- /dev/null +++ b/app/graphql/types/base_interface.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BaseInterface + include GraphQL::Schema::Interface + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb new file mode 100644 index 000000000..487af2f58 --- /dev/null +++ b/app/graphql/types/base_object.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class BaseObject < GraphQL::Schema::Object + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_scalar.rb b/app/graphql/types/base_scalar.rb new file mode 100644 index 000000000..719bc808f --- /dev/null +++ b/app/graphql/types/base_scalar.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseScalar < GraphQL::Schema::Scalar + end +end diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb new file mode 100644 index 000000000..959416963 --- /dev/null +++ b/app/graphql/types/base_union.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseUnion < GraphQL::Schema::Union + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + end +end diff --git a/app/graphql/types/component_input_type.rb b/app/graphql/types/component_input_type.rb new file mode 100644 index 000000000..08f62ecb4 --- /dev/null +++ b/app/graphql/types/component_input_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class ComponentInputType < Types::BaseInputObject + description 'Represents a project component during a mutation' + + argument :content, String, required: false, description: 'The text content of the component' + argument :default, Boolean, required: true, description: 'If this is the default component on a project' + argument :extension, String, required: true, description: 'The file extension of the component, e.g. html, csv, py' + argument :name, String, required: true, description: 'The name of the file' + argument :project_id, ID, required: false, + description: 'The ID of the project this component should be assocated with' + end +end diff --git a/app/graphql/types/component_type.rb b/app/graphql/types/component_type.rb new file mode 100644 index 000000000..943da26ff --- /dev/null +++ b/app/graphql/types/component_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + class ComponentType < Types::BaseObject + description 'A file that makes up part of a project' + implements GraphQL::Types::Relay::Node + + field :content, String, description: 'The contents of the component' + field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The time it was created' + field :default, Boolean, null: false, + description: 'True if this is the default component of a project, e.g. main.py, index.html' + field :extension, String, null: false, + description: 'The file extension name of the component, e.g. py, html, css, csv' + field :name, String, null: false, description: 'The file basename of the component, e.g. main, index, styles' + field :project, ProjectType, description: 'The project this component belongs to' + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The time it was last changed' + end +end diff --git a/app/graphql/types/image_type.rb b/app/graphql/types/image_type.rb new file mode 100644 index 000000000..1e11e1691 --- /dev/null +++ b/app/graphql/types/image_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class ImageType < Types::BaseObject + description 'An image' + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The time the image was created' + field :filename, String, null: false, description: 'The filename of the image' + field :url, String, description: 'The URL where the image can be downloaded from' + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 000000000..f40de8832 --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class MutationType < Types::BaseObject + field :create_project, mutation: Mutations::CreateProject, description: 'A mutation to create a project' + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb new file mode 100644 index 000000000..b89a8b6df --- /dev/null +++ b/app/graphql/types/project_type.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + class ProjectType < Types::BaseObject + description 'A project comprising of a number of components and images' + implements GraphQL::Types::Relay::Node + + field :components, Types::ComponentType.connection_type, + description: 'All components associated with this project' + field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The created at timestamp' + field :identifier, String, null: false, description: 'The easy-to-rememeber identifier of the project' + field :images, Types::ImageType.connection_type, + description: 'All images associated with this project' + field :name, String, description: 'The name of the project' + field :project_type, String, null: false, description: 'The type of project, e.g. python, html' + field :remixed_from, ProjectType, method: :parent, description: 'If present, the project this one was remixed from' + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The last updated timestamp' + field :user_id, Types::UuidType, description: 'The project creator\'s user ID' + + delegate :components, to: :object + + def images + object.images.to_a + end + + def self.authorized?(object, context) + super && context[:current_ability]&.can?(:show, object) + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 000000000..73490839f --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + class QueryType < Types::BaseObject + # Add `node(id: ID!) and `nodes(ids: [ID!]!)` + include GraphQL::Types::Relay::HasNodeField + include GraphQL::Types::Relay::HasNodesField + + # Add root-level fields here. + # They will be entry points for queries on your schema. + + field :project, Types::ProjectType, 'Find a project by identifier' do + argument :identifier, String, required: true, description: 'Project identifier' + end + + field :projects, Types::ProjectType.connection_type, 'All viewable projects' do + argument :user_id, String, required: false, description: 'Filter by user ID' + end + + def project(identifier:) + Project.find_by(identifier:) + end + + def projects(user_id: nil) + results = Project.accessible_by(context[:current_ability], :show).order(updated_at: :desc) + results = results.where(user_id:) if user_id + + results + end + end +end diff --git a/app/graphql/types/uuid_type.rb b/app/graphql/types/uuid_type.rb new file mode 100644 index 000000000..593741605 --- /dev/null +++ b/app/graphql/types/uuid_type.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class UuidType < GraphQL::Types::String + description 'A globally unique ID' + end +end diff --git a/config/application.rb b/config/application.rb index f329af37a..9c463589f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -42,6 +42,7 @@ class Application < Rails::Application config.generators do |g| g.orm :active_record, primary_key_type: :uuid + g.test_framework :rspec end config.active_job.queue_adapter = :good_job diff --git a/config/routes.rb b/config/routes.rb index c53576b12..af593f336 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true Rails.application.routes.draw do + post '/graphql', to: 'graphql#execute' + namespace :api do resource :default_project, only: %i[show] do get '/html', to: 'default_projects#html' diff --git a/db/schema.graphql b/db/schema.graphql new file mode 100644 index 000000000..6b975a517 --- /dev/null +++ b/db/schema.graphql @@ -0,0 +1,468 @@ +""" +A file that makes up part of a project +""" +type Component implements Node { + """ + The contents of the component + """ + content: String + + """ + The time it was created + """ + createdAt: ISO8601DateTime! + + """ + True if this is the default component of a project, e.g. main.py, index.html + """ + default: Boolean! + + """ + The file extension name of the component, e.g. py, html, css, csv + """ + extension: String! + + """ + ID of the object. + """ + id: ID! + + """ + The file basename of the component, e.g. main, index, styles + """ + name: String! + + """ + The project this component belongs to + """ + project: Project + + """ + The time it was last changed + """ + updatedAt: ISO8601DateTime! +} + +""" +The connection type for Component. +""" +type ComponentConnection { + """ + A list of edges. + """ + edges: [ComponentEdge] + + """ + A list of nodes. + """ + nodes: [Component] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + + """ + Total number of nodes available + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type ComponentEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Component +} + +""" +Represents a project component during a mutation +""" +input ComponentInput { + """ + The text content of the component + """ + content: String + + """ + If this is the default component on a project + """ + default: Boolean! + + """ + The file extension of the component, e.g. html, csv, py + """ + extension: String! + + """ + The name of the file + """ + name: String! + + """ + The ID of the project this component should be assocated with + """ + projectId: ID +} + +""" +Autogenerated input type of CreateProject +""" +input CreateProjectInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Any project components + """ + components: [ComponentInput!] + + """ + The name of the project + """ + name: String! + + """ + The type of project, e.g. python, html + """ + projectType: String! +} + +""" +Autogenerated return type of CreateProject. +""" +type CreateProjectPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The project that has been created + """ + project: Project +} + +""" +An ISO 8601-encoded datetime +""" +scalar ISO8601DateTime + +""" +An image +""" +type Image { + """ + The time the image was created + """ + createdAt: ISO8601DateTime! + + """ + The filename of the image + """ + filename: String! + + """ + The URL where the image can be downloaded from + """ + url: String +} + +""" +The connection type for Image. +""" +type ImageConnection { + """ + A list of edges. + """ + edges: [ImageEdge] + + """ + A list of nodes. + """ + nodes: [Image] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + + """ + Total number of nodes available + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type ImageEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Image +} + +type Mutation { + """ + A mutation to create a project + """ + createProject( + """ + Parameters for CreateProject + """ + input: CreateProjectInput! + ): CreateProjectPayload +} + +""" +An object with an ID. +""" +interface Node { + """ + ID of the object. + """ + id: ID! +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String + + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String +} + +""" +A project comprising of a number of components and images +""" +type Project implements Node { + """ + All components associated with this project + """ + components( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): ComponentConnection + + """ + The created at timestamp + """ + createdAt: ISO8601DateTime! + + """ + ID of the object. + """ + id: ID! + + """ + The easy-to-rememeber identifier of the project + """ + identifier: String! + + """ + All images associated with this project + """ + images( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): ImageConnection + + """ + The name of the project + """ + name: String + + """ + The type of project, e.g. python, html + """ + projectType: String! + + """ + If present, the project this one was remixed from + """ + remixedFrom: Project + + """ + The last updated timestamp + """ + updatedAt: ISO8601DateTime! + + """ + The project creator's user ID + """ + userId: Uuid +} + +""" +The connection type for Project. +""" +type ProjectConnection { + """ + A list of edges. + """ + edges: [ProjectEdge] + + """ + A list of nodes. + """ + nodes: [Project] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + + """ + Total number of nodes available + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type ProjectEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Project +} + +type Query { + """ + Fetches an object given its ID. + """ + node( + """ + ID of the object. + """ + id: ID! + ): Node + + """ + Fetches a list of objects given a list of IDs. + """ + nodes( + """ + IDs of the objects. + """ + ids: [ID!]! + ): [Node]! + + """ + Find a project by identifier + """ + project( + """ + Project identifier + """ + identifier: String! + ): Project + + """ + All viewable projects + """ + projects( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Filter by user ID + """ + userId: String + ): ProjectConnection +} + +""" +A globally unique ID +""" +scalar Uuid diff --git a/lib/hydra_admin_api.rb b/lib/hydra_admin_api.rb new file mode 100644 index 000000000..3d88bec45 --- /dev/null +++ b/lib/hydra_admin_api.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'faraday' + +class HydraAdminApi + ADMIN_URL = ENV.fetch('HYDRA_ADMIN_URL', 'http://localhost:9002') + ADMIN_API_KEY = ENV.fetch('HYDRA_ADMIN_API_KEY', 'test-key') + + # The "bypass" user ID from + # https://github.com/RaspberryPiFoundation/rpi-auth/blob/main/lib/rpi_auth/engine.rb#L17 + BYPASS_AUTH_USER_ID = 'b6301f34-b970-4d4f-8314-f877bad8b150' + + class << self + def fetch_oauth_user_id(...) + new.fetch_oauth_user_id(...) + end + end + + def fetch_oauth_user_id(token:) + return nil if token.blank? + + return BYPASS_AUTH_USER_ID if ENV.fetch('BYPASS_AUTH', nil) == 'yes' + + response = post('oauth2/introspect', { token: }, { apikey: ADMIN_API_KEY }) + response.body['sub'] + end + + private + + def conn + @conn ||= Faraday.new(ADMIN_URL) do |f| + f.request :url_encoded + f.response :raise_error + f.response :json + end + end + + def post(...) + conn.post(...) + end +end diff --git a/lib/tasks/graphql.rake b/lib/tasks/graphql.rake new file mode 100644 index 000000000..194d20135 --- /dev/null +++ b/lib/tasks/graphql.rake @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +namespace :graphql do + desc 'update db/schema.graphql to match the current EditorApiSchema to keep track of schema changes' + task dump_schema: :environment do + # see https://rmosolgo.github.io/ruby/graphql/2017/03/16/tracking-schema-changes-with-graphql-ruby + + # Get a string containing the definition in GraphQL IDL: + schema_definition = EditorApiSchema.to_definition + # Choose a place to write the schema dump: + schema_path = 'db/schema.graphql' + # Write the schema dump to that file: + Rails.root.join(schema_path).write(schema_definition) + puts "Updated Editor API GraphQL schema at #{schema_path}" + end +end diff --git a/public/index.html b/public/index.html new file mode 100644 index 000000000..9c03de995 --- /dev/null +++ b/public/index.html @@ -0,0 +1,68 @@ + + + +
+ + + + + + + + + + + +