Improve API docs for articles, add listings to it and fix bugs (#6413) [deploy]
* Fix articles index state param values
* Update Articles API docs
* Fix ClassifiedListing.cost_by_category with invalid category
* Add docs for GET|POST /listings and fix bugs
* Document PUT /api/listings/:id
* Add docs for /listings/category/{category}
* One variable down
* Improve listings update error handling
This commit is contained in:
parent
6d7cbfff1f
commit
f3e76e7fe5
13 changed files with 812 additions and 89 deletions
|
|
@ -17,6 +17,10 @@ class Api::V0::ApiController < ApplicationController
|
|||
error_not_found
|
||||
end
|
||||
|
||||
rescue_from Pundit::NotAuthorizedError do |_exc|
|
||||
error_unauthorized
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def error_unprocessable_entity(message)
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ module Api
|
|||
render json: { errors: @classified_listing.errors }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
alias process_unsuccessful_update process_unsuccessful_creation
|
||||
|
||||
def process_after_update
|
||||
render "show", status: :ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ class ClassifiedListingsController < ApplicationController
|
|||
render :new
|
||||
end
|
||||
|
||||
def process_unsuccessful_update
|
||||
render :edit
|
||||
end
|
||||
|
||||
def process_after_update
|
||||
redirect_to "/listings"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ module ClassifiedListingsToolkit
|
|||
def create
|
||||
@classified_listing = ClassifiedListing.new(listing_params)
|
||||
|
||||
# this will 500 for now if they don't belong in the org
|
||||
# this will 401 for now if they don't belong in the org
|
||||
authorize @classified_listing, :authorized_organization_poster? if @classified_listing.organization_id.present?
|
||||
|
||||
@classified_listing.user_id = current_user.id
|
||||
|
|
@ -141,7 +141,8 @@ module ClassifiedListingsToolkit
|
|||
|
||||
publish_listing
|
||||
elsif listing_updatable?
|
||||
update_listing_details
|
||||
saved = update_listing_details
|
||||
return process_unsuccessful_update unless saved
|
||||
end
|
||||
|
||||
clear_listings_cache
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ class ClassifiedListing < ApplicationRecord
|
|||
|
||||
scope :published, -> { where(published: true) }
|
||||
|
||||
def self.cost_by_category(category = "education")
|
||||
categories_available[category][:cost]
|
||||
def self.cost_by_category(category)
|
||||
categories_available.dig(category, :cost) || 0
|
||||
end
|
||||
|
||||
def author
|
||||
|
|
|
|||
|
|
@ -4,16 +4,8 @@
|
|||
<h2>Create a Community Listing</h2>
|
||||
</header>
|
||||
<div class="classified-form-inner">
|
||||
<% if classified_listing.errors.any? %>
|
||||
<div class="classified-errors">
|
||||
<h2><%= pluralize(classified_listing.errors.count, "error") %> prohibited this listing from being saved:</h2>
|
||||
<ul>
|
||||
<% classified_listing.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render partial: "form_errors", locals: { classified_listing: classified_listing } %>
|
||||
|
||||
<div id="listingform-data"
|
||||
data-listing="<%= classified_listing.to_json(only: %i[id title body_markdown category cached_tag_list]) %>"
|
||||
data-organizations="<%= @organizations.to_json(only: %i[id name]) %>"
|
||||
|
|
|
|||
10
app/views/classified_listings/_form_errors.html.erb
Normal file
10
app/views/classified_listings/_form_errors.html.erb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<% if classified_listing.errors.any? %>
|
||||
<div class="classified-errors">
|
||||
<h2><%= pluralize(classified_listing.errors.count, "error") %> prohibited this listing from being saved:</h2>
|
||||
<ul>
|
||||
<% classified_listing.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
<h2>You can only edit title/body/tags of drafts or within the first 24 hours of listing or bumping</h2>
|
||||
</header>
|
||||
<div class="classified-form-inner">
|
||||
<%= render partial: "form_errors", locals: { classified_listing: @classified_listing } %>
|
||||
|
||||
<% if (@classified_listing.bumped_at && @classified_listing.bumped_at > 24.hours.ago) || @classified_listing.updated_at && !@classified_listing.published %>
|
||||
<div class="field">
|
||||
<%= f.label "title", "Title" %>
|
||||
|
|
|
|||
735
docs/api_v0.yml
735
docs/api_v0.yml
|
|
@ -5,7 +5,10 @@ info:
|
|||
Access DEV articles, users and other resources via API.
|
||||
|
||||
All endpoints that don't require authentication are CORS enabled.
|
||||
version: "0.6.0"
|
||||
|
||||
Dates and date times, unless otherwise specified, must be in
|
||||
the [RFC 3339](https://tools.ietf.org/html/rfc3339) format.
|
||||
version: "0.6.1"
|
||||
termsOfService: https://dev.to/terms
|
||||
contact:
|
||||
name: DEV Team
|
||||
|
|
@ -158,9 +161,9 @@ components:
|
|||
type: string
|
||||
format: date-time
|
||||
user:
|
||||
$ref: "#/components/schemas/ArticleUser"
|
||||
$ref: "#/components/schemas/SharedUser"
|
||||
organization:
|
||||
$ref: "#/components/schemas/ArticleOrganization"
|
||||
$ref: "#/components/schemas/SharedOrganization"
|
||||
flare_tag:
|
||||
$ref: "#/components/schemas/ArticleFlareTag"
|
||||
|
||||
|
|
@ -256,9 +259,9 @@ components:
|
|||
body_markdown:
|
||||
type: string
|
||||
user:
|
||||
$ref: "#/components/schemas/ArticleUser"
|
||||
$ref: "#/components/schemas/SharedUser"
|
||||
organization:
|
||||
$ref: "#/components/schemas/ArticleOrganization"
|
||||
$ref: "#/components/schemas/SharedOrganization"
|
||||
flare_tag:
|
||||
$ref: "#/components/schemas/ArticleFlareTag"
|
||||
|
||||
|
|
@ -462,14 +465,185 @@ components:
|
|||
type: string
|
||||
format: date-time
|
||||
user:
|
||||
$ref: "#/components/schemas/ArticleUser"
|
||||
$ref: "#/components/schemas/SharedUser"
|
||||
organization:
|
||||
$ref: "#/components/schemas/ArticleOrganization"
|
||||
$ref: "#/components/schemas/SharedOrganization"
|
||||
flare_tag:
|
||||
$ref: "#/components/schemas/ArticleFlareTag"
|
||||
|
||||
ArticleUser:
|
||||
description: The article's creator
|
||||
ArticleFlareTag:
|
||||
description: Flare tag of the article
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
bg_color_hex:
|
||||
description: Background color (hexadecimal)
|
||||
type: string
|
||||
text_color_hex:
|
||||
description: Text color (hexadecimal)
|
||||
type: string
|
||||
|
||||
Listing:
|
||||
type: object
|
||||
required:
|
||||
- type_of
|
||||
- id
|
||||
- title
|
||||
- slug
|
||||
- body_markdown
|
||||
- tag_list
|
||||
- tags
|
||||
- category
|
||||
- processed_html
|
||||
- published
|
||||
- user
|
||||
properties:
|
||||
type_of:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
format: int32
|
||||
title:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
body_markdown:
|
||||
type: string
|
||||
tag_list:
|
||||
type: string
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
category:
|
||||
$ref: "#/components/schemas/ListingCategory"
|
||||
processed_html:
|
||||
type: string
|
||||
published:
|
||||
type: boolean
|
||||
user:
|
||||
$ref: "#/components/schemas/SharedUser"
|
||||
organization:
|
||||
$ref: "#/components/schemas/SharedOrganization"
|
||||
|
||||
ListingCategory:
|
||||
type: string
|
||||
enum: [cfp, forhire, collabs, education, jobs, mentors, products, mentees, forsale, events, misc]
|
||||
|
||||
ListingCreate:
|
||||
type: object
|
||||
properties:
|
||||
classified_listing:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body_markdown
|
||||
- category
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body_markdown:
|
||||
description: The body of the listing in Markdown format.
|
||||
type: string
|
||||
category:
|
||||
$ref: "#/components/schemas/ListingCategory"
|
||||
tags:
|
||||
description: |
|
||||
Tags related to the listing.
|
||||
|
||||
A maximum of 8 tags are allowed and it takes precedence over `tag_list`.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
tag_list:
|
||||
description: |
|
||||
Comma separated list of tags.
|
||||
|
||||
A maximum of 8 tags are allowed.
|
||||
type: string
|
||||
expires_at:
|
||||
description: Date and time of expiration.
|
||||
type: date
|
||||
format: date-time
|
||||
contact_via_connect:
|
||||
description: |
|
||||
True if users are allowed to contact the listing's owner
|
||||
via DEV connect, false otherwise.
|
||||
|
||||
Defaults to false.
|
||||
type: boolean
|
||||
location:
|
||||
description: Geographical area or city for the listing.
|
||||
type: string
|
||||
organization_id:
|
||||
description: |
|
||||
The id of the organization the user is creating the listing for.
|
||||
|
||||
Only users belonging to an organization can assign the listing to it.
|
||||
type: integer
|
||||
format: int32
|
||||
action:
|
||||
description: Set it to "draft" to create an unpublished listing
|
||||
type: string
|
||||
enum: [draft]
|
||||
ListingUpdate:
|
||||
type: object
|
||||
properties:
|
||||
classified_listing:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body_markdown:
|
||||
description: The body of the listing in Markdown format.
|
||||
type: string
|
||||
category:
|
||||
$ref: "#/components/schemas/ListingCategory"
|
||||
tags:
|
||||
description: |
|
||||
Tags related to the listing.
|
||||
|
||||
A maximum of 8 tags are allowed and it takes precedence over `tag_list`.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
tag_list:
|
||||
description: |
|
||||
Comma separated list of tags.
|
||||
|
||||
A maximum of 8 tags are allowed.
|
||||
type: string
|
||||
expires_at:
|
||||
description: Date and time of expiration.
|
||||
type: date
|
||||
format: date-time
|
||||
contact_via_connect:
|
||||
description: |
|
||||
True if users are allowed to contact the listing's owner
|
||||
via DEV connect, false otherwise.
|
||||
|
||||
Defaults to false.
|
||||
type: boolean
|
||||
location:
|
||||
description: Geographical area or city for the listing.
|
||||
type: string
|
||||
action:
|
||||
description: |
|
||||
This param can be provided by itself to invoke some actions:
|
||||
|
||||
- `bump` bumps the listing and charge either the
|
||||
organization or the user
|
||||
- `publish` publishes a draft listing
|
||||
- `unpublish` unpublishes a published listing
|
||||
|
||||
It will take priority on any other param in the payload.
|
||||
type: string
|
||||
enum: [bump, publish, unpublish]
|
||||
|
||||
|
||||
SharedUser:
|
||||
description: The resource creator
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
|
|
@ -490,8 +664,8 @@ components:
|
|||
description: Profile image (90x90)
|
||||
type: string
|
||||
|
||||
ArticleOrganization:
|
||||
description: The organization the article belongs to
|
||||
SharedOrganization:
|
||||
description: The organization the resource belongs to
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
|
|
@ -509,19 +683,6 @@ components:
|
|||
type: string
|
||||
format: url
|
||||
|
||||
ArticleFlareTag:
|
||||
description: Flare tag of the article
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
bg_color_hex:
|
||||
description: Background color (hexadecimal)
|
||||
type: string
|
||||
text_color_hex:
|
||||
description: Text color (hexadecimal)
|
||||
type: string
|
||||
|
||||
WebhookCreate:
|
||||
description: Webhook creation payload
|
||||
type: object
|
||||
|
|
@ -569,7 +730,7 @@ components:
|
|||
type: string
|
||||
format: date-time
|
||||
user:
|
||||
$ref: "#/components/schemas/ArticleUser"
|
||||
$ref: "#/components/schemas/SharedUser"
|
||||
|
||||
WebhookIndex:
|
||||
description: Webhook
|
||||
|
|
@ -608,10 +769,15 @@ components:
|
|||
value:
|
||||
error: unauthorized
|
||||
status: 401
|
||||
ErrorPaymentRequired:
|
||||
value:
|
||||
error: not enough available credits
|
||||
status: 402
|
||||
ErrorUnprocessableEntity:
|
||||
value:
|
||||
error: "param is missing or the value is empty: article"
|
||||
status: 422
|
||||
|
||||
ArticlesIndex:
|
||||
value:
|
||||
- type_of: article
|
||||
|
|
@ -742,6 +908,96 @@ components:
|
|||
series: Hello series
|
||||
canonical_url: https://example.com/blog/hello
|
||||
organization_id: 1234
|
||||
|
||||
Listings:
|
||||
value:
|
||||
- type_of: classified_listing
|
||||
id: 1157
|
||||
title: TestBash Detroit
|
||||
slug: testbash-detroit-50gb
|
||||
body_markdown: "Do you want to learn about automation? Maybe you're interested in
|
||||
AI-driven testing? Security testing? We have a selection of talks, workshops and
|
||||
training courses to help you in a wide variety of areas in software testing. Join
|
||||
us for our very first trip to Detroit MI! \n\nhttps://bit.ly/TBDetroit"
|
||||
tag_list: events
|
||||
tags:
|
||||
- events
|
||||
category: cfp
|
||||
processed_html: |
|
||||
<p>Do you want to learn about automation? Maybe you're interested in AI-driven testing? Security testing? We have a selection of talks, workshops and training courses to help you in a wide variety of areas in software testing. Join us for our very first trip to Detroit MI! </p>
|
||||
|
||||
<p><a href="https://bit.ly/TBDetroit">https://bit.ly/TBDetroit</a></p>
|
||||
published: true
|
||||
user:
|
||||
name: Heather
|
||||
username: heatherr
|
||||
twitter_username:
|
||||
github_username: Heather-R
|
||||
website_url:
|
||||
profile_image: https://res.cloudinary.com/practicaldev/image/fetch/s--ggU5WPaT--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/136256/11cced64-afd2-4421-91e1-c5f7b216d49b.jpeg
|
||||
profile_image_90: https://res.cloudinary.com/practicaldev/image/fetch/s--CjladMBD--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/136256/11cced64-afd2-4421-91e1-c5f7b216d49b.jpeg
|
||||
Listing:
|
||||
value:
|
||||
type_of: classified_listing
|
||||
id: 1157
|
||||
title: TestBash Detroit
|
||||
slug: testbash-detroit-50gb
|
||||
body_markdown: "Do you want to learn about automation? Maybe you're interested in
|
||||
AI-driven testing? Security testing? We have a selection of talks, workshops and
|
||||
training courses to help you in a wide variety of areas in software testing. Join
|
||||
us for our very first trip to Detroit MI! \n\nhttps://bit.ly/TBDetroit"
|
||||
tag_list: events
|
||||
tags:
|
||||
- events
|
||||
category: cfp
|
||||
processed_html: |
|
||||
<p>Do you want to learn about automation? Maybe you're interested in AI-driven testing? Security testing? We have a selection of talks, workshops and training courses to help you in a wide variety of areas in software testing. Join us for our very first trip to Detroit MI! </p>
|
||||
|
||||
<p><a href="https://bit.ly/TBDetroit">https://bit.ly/TBDetroit</a></p>
|
||||
published: true
|
||||
user:
|
||||
name: Heather
|
||||
username: heatherr
|
||||
twitter_username:
|
||||
github_username: Heather-R
|
||||
website_url:
|
||||
profile_image: https://res.cloudinary.com/practicaldev/image/fetch/s--ggU5WPaT--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/136256/11cced64-afd2-4421-91e1-c5f7b216d49b.jpeg
|
||||
profile_image_90: https://res.cloudinary.com/practicaldev/image/fetch/s--CjladMBD--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/136256/11cced64-afd2-4421-91e1-c5f7b216d49b.jpeg
|
||||
ListingCreate:
|
||||
value:
|
||||
classified_listing:
|
||||
title: ACME Conference
|
||||
body_markdown: Awesome conference
|
||||
category: cfp
|
||||
tags:
|
||||
- events
|
||||
ListingCreateOrganization:
|
||||
value:
|
||||
classified_listing:
|
||||
title: ACME Conference
|
||||
body_markdown: Awesome conference
|
||||
category: cfp
|
||||
tags:
|
||||
- events
|
||||
organization_id: 1
|
||||
ListingUpdate:
|
||||
value:
|
||||
classified_listing:
|
||||
title: ACME New Conference
|
||||
body_markdown: Awesome new conference
|
||||
ListingUpdateActionBump:
|
||||
value:
|
||||
classified_listing:
|
||||
action: bump
|
||||
ListingUpdateActionPublish:
|
||||
value:
|
||||
classified_listing:
|
||||
action: publish
|
||||
ListingUpdateActionUnpublish:
|
||||
value:
|
||||
classified_listing:
|
||||
action: unpublish
|
||||
|
||||
WebhookCreate:
|
||||
value:
|
||||
webhook_endpoint:
|
||||
|
|
@ -770,6 +1026,8 @@ components:
|
|||
tags:
|
||||
- name: articles
|
||||
description: Articles are all the posts users create on DEV
|
||||
- name: listings
|
||||
description: Listings are classified ads
|
||||
- name: users
|
||||
description: Users own resources that require authentication
|
||||
- name: webhooks
|
||||
|
|
@ -790,71 +1048,85 @@ paths:
|
|||
By default it will return featured, published articles ordered
|
||||
by descending popularity.
|
||||
|
||||
Each page will contain `30` articles.
|
||||
|
||||
Responses, according to the combination of params, are cached for 24 hours.
|
||||
It supports pagination, each page will contain `30` articles by default.
|
||||
tags:
|
||||
- articles
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: |
|
||||
Pagination page.
|
||||
|
||||
This param can be used in conjuction with all other params
|
||||
(except when asking for fresh and rising articles by themselves).
|
||||
description: Pagination page.
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 1
|
||||
- name: per_page
|
||||
in: query
|
||||
description: Page size (defaults to 30 with a maximum of 1000).
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 30
|
||||
- name: tag
|
||||
in: query
|
||||
description: |
|
||||
Adding this parameter will return articles that contain the
|
||||
Using this parameter will retrieve articles that contain the
|
||||
requested tag.
|
||||
|
||||
This param can be used by itself, with `page` or with `top`.
|
||||
Articles will be ordered by descending popularity.
|
||||
|
||||
This parameter can be used in conjuction with `top`.
|
||||
schema:
|
||||
type: string
|
||||
example: discuss
|
||||
- name: username
|
||||
in: query
|
||||
description: |
|
||||
Adding this parameter will return articles belonging to a User or Organization
|
||||
ordered by descending `published_at`.
|
||||
Using this parameter will retrieve articles belonging
|
||||
to a User or Organization ordered by descending publication date.
|
||||
|
||||
If `state=all` the number of items returned will be `1000` instead of the default `30`.
|
||||
|
||||
This param can be used by itself or only with `page` and `state`.
|
||||
This parameter can be used in conjuction with `state`.
|
||||
schema:
|
||||
type: string
|
||||
example: ben
|
||||
- name: state
|
||||
in: query
|
||||
description: |
|
||||
Adding this will allow the client to check which articles are fresh or rising.
|
||||
Using this parameter will allow the client to check which articles are fresh or rising.
|
||||
|
||||
If `state=fresh` the server will return published fresh articles.
|
||||
If `state=rising` the server will return published rising articles.
|
||||
If `state=fresh` the server will return fresh articles.
|
||||
If `state=rising` the server will return rising articles.
|
||||
|
||||
This param can only be used by itself or with `username` if set to `all`.
|
||||
This param can be used in conjuction with `username`, only if set to `all`.
|
||||
schema:
|
||||
type: string
|
||||
example: ben
|
||||
enum: [fresh, rising, all]
|
||||
example: fresh
|
||||
- name: top
|
||||
in: query
|
||||
description: |
|
||||
Adding this will allow the client to return the most popular articles
|
||||
Using this parameter will allow the client to return the most popular articles
|
||||
in the last `N` days.
|
||||
|
||||
`top` indicates the number of days since publication of the articles
|
||||
returned.
|
||||
|
||||
This param can only be used by itself or with `tag`.
|
||||
This param can be used in conjuction with `tag`.
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 2
|
||||
- name: collection_id
|
||||
in: query
|
||||
description: |
|
||||
Adding this will allow the client to return the list of articles
|
||||
belonging to the requested collection, ordered by ascending
|
||||
publication date.
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 99
|
||||
responses:
|
||||
200:
|
||||
description: A list of articles
|
||||
|
|
@ -978,8 +1250,6 @@ paths:
|
|||
description: |
|
||||
This endpoint allows the client to retrieve a single
|
||||
published article given its `id`.
|
||||
|
||||
Responses are cached for 5 minutes.
|
||||
tags:
|
||||
- articles
|
||||
parameters:
|
||||
|
|
@ -1019,7 +1289,7 @@ paths:
|
|||
operationId: updateArticle
|
||||
summary: Update an article
|
||||
description: |
|
||||
This endpoint allows the client to updated an existing article.
|
||||
This endpoint allows the client to update an existing article.
|
||||
|
||||
"Articles" are all the posts that users create on DEV that typically
|
||||
show up in the feed. They can be a blog post, a discussion question,
|
||||
|
|
@ -1045,7 +1315,7 @@ paths:
|
|||
example: 150589
|
||||
requestBody:
|
||||
description: |
|
||||
Article params to update.
|
||||
Article params for the update.
|
||||
|
||||
*Note: if the article contains a front matter in its body, its front
|
||||
matter properties will still take precedence over any JSON equivalent
|
||||
|
|
@ -1325,6 +1595,373 @@ paths:
|
|||
source: |
|
||||
curl -H "api-key: API_KEY" https://dev.to/api/articles/me/all
|
||||
|
||||
/listings:
|
||||
get:
|
||||
operationId: getListings
|
||||
summary: Published listings
|
||||
description: |
|
||||
This endpoint allows the client to retrieve a list of listings.
|
||||
|
||||
"Listings" are classified ads that users create on DEV.
|
||||
They can be related to conference announcements, job offers, mentorships,
|
||||
upcoming events and more.
|
||||
|
||||
By default it will return published listings ordered by descending
|
||||
freshness.
|
||||
|
||||
It supports pagination, each page will contain `30` articles by default.
|
||||
tags:
|
||||
- listings
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: Pagination page.
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 1
|
||||
- name: per_page
|
||||
in: query
|
||||
description: Page size (defaults to 30 with a maximum of 100).
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 30
|
||||
- name: category
|
||||
in: query
|
||||
description: |
|
||||
Using this parameter will return listings belonging to the
|
||||
requested category.
|
||||
schema:
|
||||
type: string
|
||||
example: cfp
|
||||
responses:
|
||||
200:
|
||||
description: A list of listings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Listing"
|
||||
examples:
|
||||
listings-success:
|
||||
$ref: "#/components/examples/Listings"
|
||||
x-code-samples: # https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-code-samples
|
||||
- lang: Shell
|
||||
label: curl (all listings)
|
||||
source: |
|
||||
curl https://dev.to/api/listings
|
||||
post:
|
||||
operationId: createListing
|
||||
summary: Create a new listing
|
||||
description: |
|
||||
This endpoint allows the client to create a new listing.
|
||||
|
||||
"Listings" are classified ads that users create on DEV.
|
||||
They can be related to conference announcements, job offers, mentorships,
|
||||
upcoming events and more.
|
||||
|
||||
The user creating the listing or the organization on which behalf the user
|
||||
is creating for need to have enough creadits for this operation to be
|
||||
successful. The server will prioritize the organization's credits over
|
||||
the user's credits.
|
||||
|
||||
### Additional resources
|
||||
|
||||
- [Rails tests for Listings API](https://github.com/thepracticaldev/dev.to/blob/master/spec/requests/api/v0/classified_listings_spec.rb)
|
||||
tags:
|
||||
- listings
|
||||
requestBody:
|
||||
description: Listing to create
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListingCreate"
|
||||
examples:
|
||||
listing-create:
|
||||
$ref: "#/components/examples/ListingCreate"
|
||||
listing-create-organization:
|
||||
$ref: "#/components/examples/ListingCreateOrganization"
|
||||
|
||||
responses:
|
||||
201:
|
||||
description: A newly created Listing
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Listing"
|
||||
examples:
|
||||
article-success:
|
||||
$ref: "#/components/examples/Listing"
|
||||
400:
|
||||
description: BadRequest
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
error-bad-request:
|
||||
$ref: "#/components/examples/ErrorBadRequest"
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
error-unauthorized:
|
||||
$ref: "#/components/examples/ErrorUnauthorized"
|
||||
402:
|
||||
description: PaymentRequired
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
error-unauthorized:
|
||||
$ref: "#/components/examples/ErrorPaymentRequired"
|
||||
422:
|
||||
description: Unprocessable Entity
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
error-unprocessable-entity:
|
||||
$ref: "#/components/examples/ErrorUnprocessableEntity"
|
||||
security:
|
||||
- api_key: []
|
||||
x-code-samples: # https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-code-samples
|
||||
- lang: Shell
|
||||
label: curl
|
||||
source: |
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-H "api-key: API_KEY" \
|
||||
-d '{"classified_listing":{"title":"Title","body_markdown":"Body","category":"cfp"}}' \
|
||||
https://dev.to/api/listings
|
||||
- lang: Shell
|
||||
label: curl (with tags)
|
||||
source: |
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-H "api-key: API_KEY" \
|
||||
-d '{"classified_listing":{"title":"Title","body_markdown":"Body","category":"cfp","tags":["python"]}}' \
|
||||
https://dev.to/api/listings
|
||||
- lang: Shell
|
||||
label: curl (draft)
|
||||
source: |
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-H "api-key: API_KEY" \
|
||||
-d '{"classified_listing":{"title":"Title","body_markdown":"Body","category":"cfp","action":"draft"}}' \
|
||||
https://dev.to/api/listings
|
||||
|
||||
/listings/category/{category}:
|
||||
get:
|
||||
operationId: getListingsByCategory
|
||||
summary: Published listings by category
|
||||
description: |
|
||||
This endpoint allows the client to retrieve a list of listings belonging
|
||||
to the specified category.
|
||||
|
||||
"Listings" are classified ads that users create on DEV.
|
||||
They can be related to conference announcements, job offers, mentorships,
|
||||
upcoming events and more.
|
||||
|
||||
By default it will return published listings ordered by descending
|
||||
freshness.
|
||||
|
||||
It supports pagination, each page will contain `30` articles by default.
|
||||
tags:
|
||||
- listings
|
||||
parameters:
|
||||
- name: category
|
||||
in: path
|
||||
required: true
|
||||
description: The category of the listing
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListingCategory"
|
||||
- name: page
|
||||
in: query
|
||||
description: Pagination page.
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 1
|
||||
- name: per_page
|
||||
in: query
|
||||
description: Page size (defaults to 30 with a maximum of 100).
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 30
|
||||
responses:
|
||||
200:
|
||||
description: A list of listings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Listing"
|
||||
examples:
|
||||
listings-success:
|
||||
$ref: "#/components/examples/Listings"
|
||||
x-code-samples: # https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-code-samples
|
||||
- lang: Shell
|
||||
label: curl (call for papers listings)
|
||||
source: |
|
||||
curl https://dev.to/api/listings/category/cfp
|
||||
|
||||
/listings/{id}:
|
||||
get:
|
||||
operationId: getListingById
|
||||
summary: A listing
|
||||
description: |
|
||||
This endpoint allows the client to retrieve
|
||||
a single listing given its `id`.
|
||||
tags:
|
||||
- listings
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Id of the listing
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 1
|
||||
responses:
|
||||
200:
|
||||
description: A listing
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Listing"
|
||||
examples:
|
||||
article-success:
|
||||
$ref: "#/components/examples/Listing"
|
||||
404:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
article-not-found:
|
||||
$ref: "#/components/examples/ErrorNotFound"
|
||||
x-code-samples: # https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-code-samples
|
||||
- lang: Shell
|
||||
label: curl
|
||||
source: |
|
||||
curl https://dev.to/api/listings/1184
|
||||
put:
|
||||
operationId: updateListing
|
||||
summary: Update a listing
|
||||
description: |
|
||||
This endpoint allows the client to update an existing listing.
|
||||
tags:
|
||||
- listings
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Id of the listing
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
example: 1184
|
||||
requestBody:
|
||||
description: |
|
||||
Listing params for the update.
|
||||
|
||||
**Note**: except for bumping, publishing and unpublishing there are the
|
||||
following restrictions on the ability to update listings:
|
||||
|
||||
- the payload has to contain at least one param among `title`, `body_markdown` or `tags`/`tag_list`
|
||||
- the listing can't be updated if it has not been bumped in the last 24 hours
|
||||
- the listing can't be updated if it has been published but not recently bumped
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListingUpdate"
|
||||
examples:
|
||||
listing-update-action-bump:
|
||||
$ref: "#/components/examples/ListingUpdateActionBump"
|
||||
listing-update-action-publish:
|
||||
$ref: "#/components/examples/ListingUpdateActionPublish"
|
||||
listing-update-action-unpublish:
|
||||
$ref: "#/components/examples/ListingUpdateActionUnpublish"
|
||||
listing-update:
|
||||
$ref: "#/components/examples/ListingUpdate"
|
||||
responses:
|
||||
200:
|
||||
description: The updated article
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
$ref: "#/components/schemas/ArticleShow"
|
||||
examples:
|
||||
article-success:
|
||||
$ref: "#/components/examples/ArticleShow"
|
||||
400:
|
||||
description: BadRequest
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
error-bad-request:
|
||||
$ref: "#/components/examples/ErrorBadRequest"
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
error-unauthorized:
|
||||
$ref: "#/components/examples/ErrorUnauthorized"
|
||||
402:
|
||||
description: PaymentRequired
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
error-unauthorized:
|
||||
$ref: "#/components/examples/ErrorPaymentRequired"
|
||||
422:
|
||||
description: Unprocessable Entity
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
$ref: "#/components/schemas/APIError"
|
||||
examples:
|
||||
error-unprocessable-entity:
|
||||
$ref: "#/components/examples/ErrorUnprocessableEntity"
|
||||
security:
|
||||
- api_key: []
|
||||
x-code-samples: # https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-code-samples
|
||||
- lang: Shell
|
||||
label: curl (bump)
|
||||
source: |
|
||||
curl -X PUT -H "Content-Type: application/json" \
|
||||
-H "api-key: API_KEY" \
|
||||
-d '{"classified_listing":{"action":"bump"}' \
|
||||
https://dev.to/api/listings/{id}
|
||||
- lang: Shell
|
||||
label: curl (update)
|
||||
source: |
|
||||
curl -X PUT -H "Content-Type: application/json" \
|
||||
-H "api-key: API_KEY" \
|
||||
-d '{"classified_listing":{"title":"Title"}' \
|
||||
https://dev.to/api/listings/{id}
|
||||
|
||||
/webhooks:
|
||||
get:
|
||||
operationId: getWebhooks
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ title: Contributing to the API Specification Docs
|
|||
|
||||
# Contributing to API spec docs
|
||||
|
||||
The API docs follow the [OpenAPI 3
|
||||
specification](http://spec.openapis.org/oas/v3.0.2).
|
||||
The API docs follow the
|
||||
[OpenAPI 3 specification](https://spec.openapis.org/oas/v3.0.3).
|
||||
|
||||
## Where the docs are located
|
||||
|
||||
|
|
@ -32,7 +32,5 @@ will reload the doc after every modification of the spec file.
|
|||
If you have Visual Studio Code, we suggest you install the following extensions
|
||||
that enable validation and navigation within the spec file:
|
||||
|
||||
- [OpenAPI (Swagger)
|
||||
editor](https://marketplace.visualstudio.com/items?itemName=42Crunch.vscode-openapi)
|
||||
- [openapi-designer live
|
||||
preview](https://marketplace.visualstudio.com/items?itemName=philosowaffle.openapi-designer)
|
||||
- [OpenAPI (Swagger) editor](https://marketplace.visualstudio.com/items?itemName=42Crunch.vscode-openapi)
|
||||
- [openapi-designer live preview](https://marketplace.visualstudio.com/items?itemName=philosowaffle.openapi-designer)
|
||||
|
|
|
|||
|
|
@ -123,4 +123,15 @@ RSpec.describe ClassifiedListing, type: :model do
|
|||
expect(Search::ClassifiedListing).to have_received(:find_document)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".cost_by_category" do
|
||||
it "returns the cost per category" do
|
||||
expected_cost = described_class::CATEGORIES_AVAILABLE.dig("cfp", "cost")
|
||||
expect(described_class.cost_by_category("cfp")).to eq(expected_cost)
|
||||
end
|
||||
|
||||
it "returns 0 with invalid category" do
|
||||
expect(described_class.cost_by_category("invalid")).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ RSpec.describe "Api::V0::Listings" do
|
|||
{
|
||||
title: "Title",
|
||||
body_markdown: "Markdown text",
|
||||
category: "cfp",
|
||||
tags: [],
|
||||
contact_via_connect: true
|
||||
category: "cfp"
|
||||
}
|
||||
end
|
||||
let(:draft_params) do
|
||||
|
|
@ -22,8 +20,6 @@ RSpec.describe "Api::V0::Listings" do
|
|||
title: "Title draft",
|
||||
body_markdown: "Markdown draft text",
|
||||
category: "cfp",
|
||||
tags: [],
|
||||
contact_via_connect: true,
|
||||
action: "draft"
|
||||
}
|
||||
end
|
||||
|
|
@ -141,7 +137,7 @@ RSpec.describe "Api::V0::Listings" do
|
|||
org
|
||||
end
|
||||
|
||||
describe "user cannot proceed if not properly unauthorizedœ" do
|
||||
describe "user cannot proceed if not properly unauthorized" do
|
||||
let(:api_secret) { create(:api_secret) }
|
||||
|
||||
it "fails with no api key" do
|
||||
|
|
@ -181,11 +177,22 @@ RSpec.describe "Api::V0::Listings" do
|
|||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it "fails if a mandatory param is missing" do
|
||||
it "fails if body_markdown is missing" do
|
||||
post_classified_listing(invalid_params)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it "fails if category is missing" do
|
||||
post_classified_listing(title: "Title", body_markdown: "body")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it "fails if category is invalid" do
|
||||
post_classified_listing(title: "Title", body_markdown: "body", category: "unknown")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body.dig("errors", "category").first).to match(/not a valid category/)
|
||||
end
|
||||
|
||||
it "does not subtract credits or create a listing if the listing is not valid" do
|
||||
expect do
|
||||
post_classified_listing(invalid_params)
|
||||
|
|
@ -200,6 +207,8 @@ RSpec.describe "Api::V0::Listings" do
|
|||
|
||||
it "properly deducts the amount of credits" do
|
||||
post_classified_listing(listing_params)
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
listing_cost = ClassifiedListing.categories_available[:cfp][:cost]
|
||||
expect(user.credits.spent.size).to eq(listing_cost)
|
||||
end
|
||||
|
|
@ -221,12 +230,18 @@ RSpec.describe "Api::V0::Listings" do
|
|||
|
||||
it "does not create a listing draft for an org not belonging to the user" do
|
||||
org = create(:organization)
|
||||
expect { post_classified_listing(draft_params.merge(organization_id: org.id)) }.to raise_error(Pundit::NotAuthorizedError)
|
||||
expect do
|
||||
post_classified_listing(draft_params.merge(organization_id: org.id))
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end.to change(ClassifiedListing, :count).by(0)
|
||||
end
|
||||
|
||||
it "does not create a listing for an org not belonging to the user" do
|
||||
org = create(:organization)
|
||||
expect { post_classified_listing(listing_params.merge(organization_id: org.id)) }.to raise_error(Pundit::NotAuthorizedError)
|
||||
expect do
|
||||
post_classified_listing(listing_params.merge(organization_id: org.id))
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end.to change(ClassifiedListing, :count).by(0)
|
||||
end
|
||||
|
||||
it "assigns the spent credits to the listing" do
|
||||
|
|
@ -278,9 +293,12 @@ RSpec.describe "Api::V0::Listings" do
|
|||
post_classified_listing(listing_params)
|
||||
expect(response).to have_http_status(:created)
|
||||
end.to change(ClassifiedListing, :count).by(1)
|
||||
expect(ClassifiedListing.find(response.parsed_body["id"]).title).to eq("Title")
|
||||
expect(ClassifiedListing.find(response.parsed_body["id"]).body_markdown).to eq("Markdown text")
|
||||
expect(ClassifiedListing.find(response.parsed_body["id"]).category).to eq("cfp")
|
||||
|
||||
listing = ClassifiedListing.find(response.parsed_body["id"])
|
||||
|
||||
expect(listing.title).to eq(listing_params[:title])
|
||||
expect(listing.body_markdown).to eq(listing_params[:body_markdown])
|
||||
expect(listing.category).to eq(listing_params[:category])
|
||||
end
|
||||
|
||||
it "creates a classified listing with a location" do
|
||||
|
|
@ -298,8 +316,11 @@ RSpec.describe "Api::V0::Listings" do
|
|||
post_classified_listing(params)
|
||||
expect(response).to have_http_status(:created)
|
||||
end.to change(ClassifiedListing, :count).by(1)
|
||||
expect(ClassifiedListing.find(response.parsed_body["id"]).cached_tag_list).to eq("discuss, javascript")
|
||||
expect(ClassifiedListing.find(response.parsed_body["id"]).contact_via_connect).to eq(true)
|
||||
|
||||
listing = ClassifiedListing.find(response.parsed_body["id"])
|
||||
|
||||
expect(listing.cached_tag_list).to eq("discuss, javascript")
|
||||
expect(listing.contact_via_connect).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -325,7 +346,7 @@ RSpec.describe "Api::V0::Listings" do
|
|||
|
||||
let(:user) { create(:user) }
|
||||
let(:another_user) { create(:user) }
|
||||
let(:listing) { create(:classified_listing, user_id: user.id) }
|
||||
let!(:listing) { create(:classified_listing, user: user) }
|
||||
let(:another_user_listing) { create(:classified_listing, user_id: another_user.id) }
|
||||
let(:listing_draft) { create(:classified_listing, user: user) }
|
||||
let(:organization) { create(:organization) }
|
||||
|
|
@ -337,7 +358,7 @@ RSpec.describe "Api::V0::Listings" do
|
|||
org_listing_draft.update_columns(bumped_at: nil, published: false)
|
||||
end
|
||||
|
||||
describe "user cannot proceed if not properly unauthorizedœ" do
|
||||
describe "user cannot proceed if not properly unauthorized" do
|
||||
let(:api_secret) { create(:api_secret) }
|
||||
|
||||
it "fails with no api key" do
|
||||
|
|
@ -481,11 +502,17 @@ RSpec.describe "Api::V0::Listings" do
|
|||
include_context "when user is authorized"
|
||||
include_context "when user has enough credit"
|
||||
|
||||
it "returns HTTP 422 if no params given" do
|
||||
it "fails if no params have been given" do
|
||||
put_classified_listing(listing.id)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it "fails if category is invalid" do
|
||||
put_classified_listing(listing.id, title: "New title", category: "unknown")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body.dig("errors", "category").first).to match(/not a valid category/)
|
||||
end
|
||||
|
||||
it "updates the title of his listing" do
|
||||
put_classified_listing(listing.id, title: "This is a new title")
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
|
@ -507,9 +534,36 @@ RSpec.describe "Api::V0::Listings" do
|
|||
end
|
||||
|
||||
it "cannot update another user listing" do
|
||||
expect do
|
||||
put_classified_listing(another_user_listing.id, title: "Test for a new title")
|
||||
end.to raise_error(Pundit::NotAuthorizedError)
|
||||
put_classified_listing(another_user_listing.id, title: "Test for a new title")
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it "updates details if the listing has been bumped in the last 24 hours" do
|
||||
listing.update!(bumped_at: 3.minutes.ago)
|
||||
|
||||
new_title = Faker::Book.title
|
||||
|
||||
put_classified_listing(listing.id, title: new_title)
|
||||
expect(listing.reload.title).to eq(new_title)
|
||||
end
|
||||
|
||||
it "does not update details if the listing hasn't been bumped in the last 24 hours" do
|
||||
listing.update!(bumped_at: 24.hours.ago)
|
||||
|
||||
new_title = Faker::Book.title
|
||||
|
||||
put_classified_listing(listing.id, title: new_title)
|
||||
expect(listing.reload.title).to eq(listing.title)
|
||||
end
|
||||
|
||||
it "does not update a published listing" do
|
||||
listing.update!(bumped_at: nil, published: true)
|
||||
|
||||
old_title = listing.title
|
||||
new_title = Faker::Book.title
|
||||
|
||||
put_classified_listing(listing.id, title: new_title)
|
||||
expect(listing.reload.title).to eq(old_title)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -389,6 +389,14 @@ RSpec.describe "ClassifiedListings", type: :request do
|
|||
expect(listing.reload.published).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "when an update is attempted" do
|
||||
it "does not update with an empty body markdown" do
|
||||
put "/listings/#{listing.id}", params: { classified_listing: { body_markdown: "" } }
|
||||
expect(response.body).to include(CGI.escapeHTML("can't be blank"))
|
||||
expect(listing.reload.body_markdown).not_to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "DEL /listings/:id" do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue