Skip to content

Commit

Permalink
Preparation of endpoint and files required for ChatGPT Plugin (forem#…
Browse files Browse the repository at this point in the history
…19394)

* feat: add the ./wellknown/ai-plugin.json file

* feat: add a logo

* feat: firts attempt at open_api file with endpoint we thought we'd use

* feat: add a search action to teh articles controller that build up teh appropriate json

* feat: add a query param to the article index

* feat: add some chatgpt endpoints and file paths to CORS

* feat: update the openapi yml file to reflect the article search endpoint

* fix: use the correct concept

* feat: replace the logo

* feat: update the ai-json file

* feat: update the openapi file

* feat: update the api and it's params

* feat: update the search query

* fix: ordering

* feat: cors debug

* fix: logic with markdown

* chore: fix test

* spec: article

* spec: article

* spec: article

* Update public/.well-known/ai-plugin.json

* Update public/openapi.yml

---------

Co-authored-by: Ben Halpern <[email protected]>
  • Loading branch information
Ridhwana and benhalpern authored Apr 27, 2023
1 parent 1a548a4 commit e67454f
Show file tree
Hide file tree
Showing 12 changed files with 461 additions and 0 deletions.
20 changes: 20 additions & 0 deletions app/controllers/concerns/api/articles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ module ArticlesController
updated_at video_thumbnail_url reading_time
].freeze

ADDITIONAL_SEARCH_ATTRIBUTES_FOR_SERIALIZATION = [
*INDEX_ATTRIBUTES_FOR_SERIALIZATION, :body_markdown
].freeze
private_constant :ADDITIONAL_SEARCH_ATTRIBUTES_FOR_SERIALIZATION

SHOW_ATTRIBUTES_FOR_SERIALIZATION = [
*INDEX_ATTRIBUTES_FOR_SERIALIZATION, :body_markdown, :processed_html
].freeze
Expand Down Expand Up @@ -118,6 +123,21 @@ def unpublish
end
end

def search
# I temporarily added a new search endpoint in the interest of getting the chatGPT plugin live without changing
# the existing index endpoint. There are some experiments which we want to conduct which I think makes sense on
# a new endpoint rather than an existing one. We may want to refactor the index one in the future.
@articles = Articles::ApiSearchQuery.call(params)

# This adds some inconsistency where we omit the body markdown when the response has more than 1 article because
# ChatGPT cannot process the long body request.
@articles = if @articles.count > 1
@articles.select(INDEX_ATTRIBUTES_FOR_SERIALIZATION).decorate
else
@articles.select(ADDITIONAL_SEARCH_ATTRIBUTES_FOR_SERIALIZATION).decorate
end
end

private

def per_page_max
Expand Down
50 changes: 50 additions & 0 deletions app/queries/articles/api_search_query.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
module Articles
class ApiSearchQuery
DEFAULT_PER_PAGE = 30

def self.call(...)
new(...).call
end

def initialize(params)
@q = params[:q]
@top = params[:top]
@page = params[:page].to_i
@per_page = [(params[:per_page] || DEFAULT_PER_PAGE).to_i, per_page_max].min
end

def call
@articles = published_articles_with_users_and_organizations

if q.present?
@articles = query_articles
end

if top.present?
@articles = top_articles.order(public_reactions_count: :desc)
end

@articles.page(page).per(per_page || DEFAULT_PER_PAGE)
end

private

attr_reader :q, :top, :page, :per_page

def per_page_max
(ApplicationConfig["API_PER_PAGE_MAX"] || 1000).to_i
end

def query_articles
@articles.search_articles(q)
end

def top_articles
@articles.where("published_at > ?", top.to_i.days.ago)
end

def published_articles_with_users_and_organizations
Article.published.includes([{ user: :profile }, :organization]).order(hotness_score: :desc)
end
end
end
23 changes: 23 additions & 0 deletions app/views/api/v0/articles/search.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
json.array! @articles do |article|
json.partial! "api/v0/articles/article", article: article

json.body_markdown article.body_markdown if article.respond_to?(:body_markdown)

# /api/articles and /api/articles/:id have opposite representations
# of `tag_list` and `tags and we can't align them without breaking the API,
# this is fully documented in the API docs
# see <https://github.com/forem/forem/issues/4206> for more details
json.tag_list article.cached_tag_list_array
json.tags article.cached_tag_list

json.partial! "api/v0/shared/user", user: article.user

if article.organization
json.partial! "api/v0/shared/organization", organization: article.organization
end

flare_tag = FlareTag.new(article).tag
if flare_tag
json.partial! "api/v0/articles/flare_tag", flare_tag: flare_tag
end
end
23 changes: 23 additions & 0 deletions app/views/api/v1/articles/search.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
json.array! @articles do |article|
json.partial! "api/v1/articles/article", article: article

json.body_markdown article.body_markdown if article.respond_to?(:body_markdown)

# /api/articles and /api/articles/:id have opposite representations
# of `tag_list` and `tags and we can't align them without breaking the API,
# this is fully documented in the API docs
# see <https://github.com/forem/forem/issues/4206> for more details
json.tag_list article.cached_tag_list_array
json.tags article.cached_tag_list

json.partial! "api/v1/shared/user", user: article.user

if article.organization
json.partial! "api/v1/shared/organization", organization: article.organization
end

flare_tag = FlareTag.new(article).tag
if flare_tag
json.partial! "api/v1/articles/flare_tag", flare_tag: flare_tag
end
end
6 changes: 6 additions & 0 deletions config/initializers/cors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
source # echo back the client's `Origin` header instead of using `*`
end

resource "/.well-known/ai-plugin.json"
resource "/openapi.yml"
resource "/api/articles/search/*", methods: %i[head get options], headers: %w[content-type openai-conversation-id
openai-ephemeral-user-id],
max_age: 2.hours.to_i

# allowed public APIs
%w[articles comments listings podcast_episodes tags users videos].each do |resource_name|
# allow read operations, disallow custom headers (eg. api-key) and enable preflight caching
Expand Down
2 changes: 2 additions & 0 deletions config/routes/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

resources :articles, only: %i[index show create update] do
collection do
get "/search", to: "articles#search"
get "me(/:status)", to: "articles#me", constraints: { status: /published|unpublished|all/ }
get "/:username/:slug", to: "articles#show_by_slug"
get "/latest", to: "articles#index", defaults: { sort: "desc" }
end
end

resources :comments, only: %i[index show]
resources :videos, only: [:index]
resources :podcast_episodes, only: [:index]
Expand Down
18 changes: 18 additions & 0 deletions public/.well-known/ai-plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"schema_version": "v1",
"name_for_human": "DEV Community",
"name_for_model": "dev",
"description_for_human": "Plugin for recommending articles or users from DEV Community.",
"description_for_model": "Plugin for recommending articles or users from DEV Community. Always link to a url for the resource returned.",
"auth": {
"type": "none"
},
"api": {
"type": "openapi",
"url": "https://dev.to/openapi.yml",
"is_user_authenticated": false
},
"logo_url": "https://dev.to/logo.png",
"contact_email": "[email protected]",
"legal_info_url": "https://dev.to/terms"
}
Binary file added public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 112 additions & 0 deletions public/openapi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
openapi: 3.0.1
# We start by defining the specification version, the title, description, and version number. When a query is run in ChatGPT, it will look at the description that is defined in the info section to determine if the plugin is relevant for the user query.
info:
title: DEV Community
description: A plugin that recommends resources like articles or users to a user using ChatGP.
version: 'v1'
servers:
- url: https://dev.to
paths:
/api/articles/search:
get:
operationId: getArticles
summary: Get a list of filtered articles
parameters:
- in: "query"
name: "q"
required: false
description: "Accepts keywords to use as a search query."
schema:
type: "string"
- in: "query"
name: "page"
required: false
description: "Pagination Page"
schema:
type: "integer"
format: "int32"
minimum: 0
default: 0
- in: "query"
name: "per_page"
required: false
description: "Page size (the number of items to return per page)."
schema:
type: "integer"
format: "int32"
minimum: 1
maximum: 100
default: 60
- in: "query"
name: "top"
required: false
description: "Returns the most popular articles in the last N days. 'top' indicates the number of days since publication of the articles returned. This param can be used in conjuction with q or tag."
schema:
type: "string"
responses:
"200":
description: OK
content:
application/vnd.forem.api-v1+json:
schema:
$ref: '#/components/schemas/getArticlesResponse'
components:
schemas:
getArticlesResponse:
description: "Representation of an article returned in a list"
type: "object"
properties:
type_of: { type: "string" }
id: { type: "integer", format: "int32" }
title: { type: "string", description: "The article title" }
description: { type: "string", description: "A description of the article" }
cover_image: { type: "string", format: "url", nullable: true }
readable_publish_date: { type: "string" }
social_image: { type: "string", format: "url" }
tag_list:
type: "array"
description: "An array representation of the tags that are associated with an article"
items:
type: "string"
tags: { type: "string", description: "An array representation of the tags that are associated with an article" }
slug: { type: "string" }
path: { description: "A relative path of the article.", type: "string", format: "path" }
url: { type: "string", format: "url", description: "The url of the article. Can be used to link to the article." }
body_markdown: {type: "string", description: "The body of the article" }
canonical_url: { type: "string", format: "url" }
positive_reactions_count: { type: "integer", format: "int32" }
public_reactions_count: { type: "integer", format: "int32" }
created_at: { type: "string", format: "date-time" }
edited_at: { type: "string", format: "date-time", nullable: true }
crossposted_at: { type: "string", format: "date-time", nullable: true }
published_at: { type: "string", format: "date-time" }
last_comment_at: { type: "string", format: "date-time" }
published_timestamp: { description: "Crossposting or published date time", type: "string",
format: "date-time" }
reading_time_minutes: { description: "Reading time, in minutes", type: "integer", format: "int32" }
user:
$ref: "#/components/schemas/SharedUser"
organization:
$ref: "#/components/schemas/SharedOrganization"
required: ["type_of", "id", "title", "description", "cover_image", "readable_publish_date", "social_image", "tag_list", "tags", "slug", "path", "url", "canonical_url", "comments_count", "positive_reactions_count", "public_reactions_count", "created_at", "edited_at", "crossposted_at", "published_at", "last_comment_at", "published_timestamp", "user", "reading_time_minutes"]
SharedUser:
description: "The author"
type: "object"
properties:
name: { type: "string" }
username: { type: "string" }
twitter_username: { type: "string", nullable: true }
github_username: { type: "string", nullable: true }
website_url: { type: "string", format: :url, nullable: true }
profile_image: { description: "Profile image (640x640)", type: "string" }
profile_image_90: { description: "Profile image (90x90)", type: "string" }
SharedOrganization:
description: "The organization the resource belongs to"
type: "object"
properties:
name: { type: "string" }
username: { type: "string" }
slug: { type: "string" }
profile_image: { description: "Profile image (640x640)", type: "string", format: :url }
profile_image_90: { description: "Profile image (90x90)", type: "string", format: :url }

37 changes: 37 additions & 0 deletions spec/queries/articles/api_search_query_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
require "rails_helper"

RSpec.describe Articles::ApiSearchQuery, type: :query do
before do
create(:article, published: false)
create(:article, title: "Top ten Interview tips")
create(:article, title: "Top ten Ruby tips")
create(:article, title: "Frontend Frameworks")
create(:article)
end

context "when there is no query parameter" do
it "shows all published and approved articles" do
articles = described_class.call({})
# The one not included has publiched set to false.
expect(articles.count).to eq(4)
end
end

context "when there is a query parameter" do
it "shows articles that match that query" do
articles = described_class.call({ q: "ruby" })
expect(articles.count).to eq(1)
end
end

context "when there is a top parameter" do
it "shows the most popular articles in the last n days" do
article = Article.find_by(title: "Frontend Frameworks")
article.update_column(:published_at, 30.days.ago)

# The two not included is the one that has publiched set to false.
articles = described_class.call({ top: 10 })
expect(articles.count).to eq(3)
end
end
end
Loading

0 comments on commit e67454f

Please sign in to comment.