# Discourse Platform Documentation ## Introduction Discourse is a comprehensive, open-source community platform built with Ruby on Rails and Ember.js. It provides a modern forum experience with real-time chat, advanced moderation tools, and extensive customization through a plugin architecture. The platform is designed to handle large-scale communities with features like multi-site support, sophisticated permission systems, and full-text search capabilities. Discourse has been battle-tested for over a decade and powers thousands of communities worldwide. The codebase is structured as a monolithic Rails application with an Ember.js frontend, supporting 44+ official plugins, extensive API endpoints, and a robust service-oriented architecture. It leverages PostgreSQL for data persistence, Redis for caching and real-time features, and Sidekiq for background job processing. The platform emphasizes accessibility, mobile responsiveness, and performance optimization, making it suitable for communities ranging from small hobby groups to large enterprise deployments. ## Core APIs and Functions ### Posts API - Create and Manage Forum Posts RESTful endpoint for creating, reading, updating, and deleting posts within topics. ```ruby # File: app/controllers/posts_controller.rb class PostsController < ApplicationController def create manager = NewPostManager.new( current_user, raw: params[:raw], topic_id: params[:topic_id], reply_to_post_number: params[:reply_to_post_number], category: params[:category], archetype: params[:archetype] ) result = manager.perform if result.success? post = result.post PostAlerter.post_created(post) render_serialized(post, PostSerializer) else render json: { errors: result.errors }, status: :unprocessable_entity end end def update post = Post.find(params[:id]) guardian.ensure_can_edit!(post) revisor = PostRevisor.new(post, current_user) revisor.revise!(current_user, { raw: params[:post][:raw] }) render_serialized(post, PostSerializer) end end # API Usage Example # POST /posts.json curl -X POST "https://discourse.example.com/posts.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: username" \ -H "Content-Type: application/json" \ -d '{ "topic_id": 123, "raw": "This is the post content in Markdown format.", "reply_to_post_number": 1 }' # Response (201 Created) { "id": 456, "username": "user123", "created_at": "2025-01-15T10:30:00.000Z", "cooked": "

This is the post content in Markdown format.

", "post_number": 2, "topic_id": 123, "reply_count": 0 } # PUT /posts/:id.json curl -X PUT "https://discourse.example.com/posts/456.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: username" \ -H "Content-Type: application/json" \ -d '{ "post": { "raw": "Updated post content with corrections." } }' ``` ### Topics API - Manage Discussion Topics Create and manage forum topics with metadata, category assignment, and visibility controls. ```ruby # File: app/controllers/topics_controller.rb class TopicsController < ApplicationController def show topic = Topic.find_by(id: params[:id]) guardian.ensure_can_see!(topic) opts = { page: params[:page]&.to_i || 1, post_number: params[:post_number], username_filters: params[:username_filters] } topic_view = TopicView.new(topic.id, current_user, opts) render_json_dump( TopicViewSerializer.new(topic_view, scope: guardian, root: false) ) end def create topic_params = { title: params[:title], raw: params[:raw], category: params[:category_id], tags: params[:tags], archetype: params[:archetype] || "regular" } topic_creator = TopicCreator.new(current_user, guardian, topic_params) topic = topic_creator.create render_serialized(topic, TopicSerializer) end def status topic = Topic.find(params[:topic_id]) guardian.ensure_can_moderate!(topic) status = params[:status] # "closed", "visible", "pinned", "archived" enabled = params[:enabled] == "true" TopicStatusUpdater.new(topic, current_user).update!(status, enabled) render json: success_json end end # API Usage Example # GET /t/:id.json curl "https://discourse.example.com/t/123.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: username" # Response { "post_stream": { "posts": [ { "id": 456, "username": "user123", "cooked": "

First post content

", "post_number": 1, "created_at": "2025-01-15T10:00:00.000Z" } ] }, "id": 123, "title": "How to use Discourse API", "category_id": 5, "views": 142, "like_count": 12, "posts_count": 8, "visible": true, "closed": false, "archived": false } # POST /posts.json (creates topic + first post) curl -X POST "https://discourse.example.com/posts.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: username" \ -H "Content-Type: application/json" \ -d '{ "title": "New Topic Title", "raw": "This is the first post content.", "category": 5, "tags": ["api", "tutorial"] }' # PUT /t/:id/status.json curl -X PUT "https://discourse.example.com/t/123/status.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: username" \ -H "Content-Type: application/json" \ -d '{ "status": "closed", "enabled": "true" }' ``` ### Users API - User Management and Profiles Retrieve and update user information, including profiles, preferences, and activity. ```ruby # File: app/controllers/users_controller.rb class UsersController < ApplicationController def show user = User.find_by_username(params[:username]) guardian.ensure_can_see!(user) serializer = UserSerializer.new( user, scope: guardian, root: false, include_user_status: true ) render_json_dump(serializer) end def update user = User.find(params[:id]) guardian.ensure_can_edit!(user) user_updater = UserUpdater.new(current_user, user) result = user_updater.update( name: params[:name], bio_raw: params[:bio_raw], website: params[:website], location: params[:location] ) if result render_serialized(user, UserSerializer) else render json: { errors: user.errors.full_messages }, status: :unprocessable_entity end end def create user = User.new( email: params[:email], username: params[:username], name: params[:name], password: params[:password] ) if user.save render_serialized(user, UserSerializer) else render json: { errors: user.errors.full_messages }, status: :unprocessable_entity end end end # API Usage Example # GET /u/:username.json curl "https://discourse.example.com/u/john_doe.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: admin" # Response { "user": { "id": 789, "username": "john_doe", "name": "John Doe", "avatar_template": "/user_avatar/discourse.example.com/john_doe/{size}/123.png", "email": "john@example.com", "trust_level": 2, "moderator": false, "admin": false, "created_at": "2024-01-01T00:00:00.000Z", "bio_cooked": "

Developer and community enthusiast

", "website": "https://johndoe.com", "location": "San Francisco, CA" } } # PUT /u/:username.json curl -X PUT "https://discourse.example.com/u/john_doe.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: john_doe" \ -H "Content-Type: application/json" \ -d '{ "name": "John Q. Doe", "bio_raw": "Senior developer and open source contributor", "website": "https://johnqdoe.com", "location": "Seattle, WA" }' # POST /users.json (admin only) curl -X POST "https://discourse.example.com/users.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: admin" \ -H "Content-Type: application/json" \ -d '{ "email": "newuser@example.com", "username": "newuser", "name": "New User", "password": "secure_password_123", "active": true }' ``` ### Guardian Authorization - Permission System Centralized authorization system that controls user access to resources and actions. ```ruby # File: lib/guardian.rb class Guardian def initialize(user = nil, request = nil) @user = user.presence || Guardian::AnonymousUser.new @request = request end def authenticated? @user.present? end def is_admin? @user.admin? end def is_staff? @user.staff? end def ensure_can_see!(obj) raise Discourse::InvalidAccess unless can_see?(obj) end def ensure_can_edit!(obj) raise Discourse::InvalidAccess unless can_edit?(obj) end end # File: lib/guardian/post_guardian.rb module PostGuardian def can_see_post?(post) return false if post.nil? return false if post.deleted_at && !can_see_deleted_post?(post) return false unless can_see_topic?(post.topic) true end def can_edit_post?(post) return false if @user.blank? return false if post.topic&.archived? return false if post.user_deleted? && !is_staff? return true if is_admin? return true if is_moderator? && post.topic&.category&.reviewable_by_moderators? return true if post.user_id == @user.id && can_edit_own_post?(post) false end def can_delete_post?(post) return false if post.nil? || @user.blank? return true if is_staff? # Regular users can delete their own posts within time limit if post.user_id == @user.id return false if post.post_number == 1 && post.topic.posts_count > 1 return false if post.created_at < SiteSetting.post_edit_time_limit.seconds.ago return true end false end end # Usage in Controllers class PostsController < ApplicationController def update post = Post.find(params[:id]) # Raises Discourse::InvalidAccess if unauthorized guardian.ensure_can_edit!(post) # Proceed with update revisor = PostRevisor.new(post, current_user) revisor.revise!(current_user, params[:post]) end def destroy post = Post.find(params[:id]) if guardian.can_delete_post?(post) PostDestroyer.new(current_user, post).destroy render json: success_json else raise Discourse::InvalidAccess end end end # Example: Custom Guardian Extension # In a plugin or lib file module CustomGuardian def can_access_private_feature? return false unless authenticated? @user.in_any_groups?(SiteSetting.private_feature_allowed_groups_map) end end # Register the extension Guardian.class_eval do include CustomGuardian end ``` ### Service Objects - Business Logic Encapsulation Service objects extract complex business logic from controllers into reusable, testable components. ```ruby # File: app/services/post_alerter.rb class PostAlerter USER_BATCH_SIZE = 100 def self.post_created(post, opts = {}) PostAlerter.new(opts).after_save_post(post, true) post end def after_save_post(post, new_record = false) return if post.topic.blank? return if !post.topic.visible? if new_record notify_users_after_post_creation(post) notify_group_summary(post) end notify_edit_watchers(post) unless new_record end def notify_users_after_post_creation(post) notify_mentioned_users(post) notify_quoted_users(post) notify_group_mentions(post) notify_watching_users(post) end def notify_mentioned_users(post) mentions = extract_mentions(post) mentions.each do |username| user = User.find_by_username(username) next if user.nil? || user.id == post.user_id create_notification( user: user, notification_type: Notification.types[:mentioned], post: post ) end end private def create_notification(user:, notification_type:, post:) return if user.suspended? Notification.create!( notification_type: notification_type, user_id: user.id, topic_id: post.topic_id, post_number: post.post_number, data: { topic_title: post.topic.title }.to_json ) MessageBus.publish( "/notification-alert/#{user.id}", { notification_type: notification_type, post_number: post.post_number, topic_title: post.topic.title }, user_ids: [user.id] ) end end # Usage Example in Controller class PostsController < ApplicationController def create post = Post.create!( user: current_user, topic_id: params[:topic_id], raw: params[:raw] ) # Delegate notification logic to service PostAlerter.post_created(post) render_serialized(post, PostSerializer) end end # Example: Creating a Custom Service # File: app/services/topic_merger.rb class TopicMerger def initialize(destination_topic, source_topic, user) @destination = destination_topic @source = source_topic @user = user end def merge! raise "Cannot merge topics from different categories" unless compatible? ActiveRecord::Base.transaction do move_posts update_user_actions transfer_topic_users close_source_topic log_staff_action end { success: true, destination_topic_id: @destination.id } rescue => e { success: false, error: e.message } end private def compatible? @destination.category_id == @source.category_id end def move_posts @source.posts.each do |post| post.update!(topic_id: @destination.id) end @destination.reload @destination.update_statistics end def close_source_topic TopicStatusUpdater.new(@source, @user).update!("closed", true) end end # Usage destination = Topic.find(123) source = Topic.find(456) result = TopicMerger.new(destination, source, current_user).merge! if result[:success] render json: success_json.merge(topic_id: result[:destination_topic_id]) else render json: { error: result[:error] }, status: :unprocessable_entity end ``` ### Site Settings - Configuration Management Centralized configuration system accessible throughout the application. ```yaml # File: config/site_settings.yml required: title: client: true default: "Discourse" site_description: default: "" contact_email: default: "noreply@unconfigured.discourse.org" type: email client: true min_post_length: default: 20 min: 1 max: 500 client: true max_post_length: default: 32000 min: 5000 max: 99000000 client: true allow_duplicate_topic_titles: default: false post_undo_action_window_mins: default: 10 client: true enable_mentions: default: true client: true max_mentions_per_post: default: 10 min: 0 posting: min_first_post_length: default: 20 min: 1 max: 500 client: true min_trust_to_post_links: default: 0 enum: "TrustLevelSetting" client: true newuser_max_links: default: 2 client: true newuser_max_images: default: 1 client: true ``` ```ruby # Accessing Site Settings in Ruby class PostsController < ApplicationController def create raw_content = params[:raw] # Validate length if raw_content.length < SiteSetting.min_post_length return render json: { errors: ["Post must be at least #{SiteSetting.min_post_length} characters"] }, status: :unprocessable_entity end if raw_content.length > SiteSetting.max_post_length return render json: { errors: ["Post cannot exceed #{SiteSetting.max_post_length} characters"] }, status: :unprocessable_entity end # Check mentions limit mentions = extract_mentions(raw_content) if mentions.count > SiteSetting.max_mentions_per_post return render json: { errors: ["Cannot mention more than #{SiteSetting.max_mentions_per_post} users"] }, status: :unprocessable_entity end post = Post.create!(user: current_user, raw: raw_content) render_serialized(post, PostSerializer) end end # Accessing in Models class Post < ActiveRecord::Base validate :check_length def check_length if raw.length < SiteSetting.min_post_length errors.add(:raw, "is too short") end end end ``` ```javascript // Accessing Site Settings in JavaScript/Ember // File: frontend/discourse/app/components/composer-body.js import Component from "@ember/component"; import { service } from "@ember/service"; export default class ComposerBody extends Component { @service siteSettings; get minLength() { return this.siteSettings.min_post_length; } get maxLength() { return this.siteSettings.max_post_length; } validateContent() { const content = this.model.reply; if (content.length < this.siteSettings.min_post_length) { this.flashNotice = `Post must be at least ${this.siteSettings.min_post_length} characters`; return false; } if (content.length > this.siteSettings.max_post_length) { this.flashNotice = `Post cannot exceed ${this.siteSettings.max_post_length} characters`; return false; } return true; } } ``` ```ruby # Plugin Site Settings # File: plugins/discourse-solved/config/settings.yml plugins: solved_enabled: default: false client: true allow_solved_on_all_topics: default: false accept_all_solutions_trust_level: default: 4 enum: "TrustLevelSetting" empty_box_on_unsolved: default: false client: true # Access in Plugin Code # File: plugins/discourse-solved/plugin.rb after_initialize do TopicView.class_eval do def can_accept_answer? return false unless SiteSetting.solved_enabled return true if @user&.admin? trust_level = SiteSetting.accept_all_solutions_trust_level @user&.has_trust_level?(trust_level) end end end ``` ### Ember Routes - Frontend Navigation Route definitions handle URL routing and data loading for the Ember.js frontend. ```javascript // File: frontend/discourse/app/routes/topic.js import { action } from "@ember/object"; import { cancel, schedule } from "@ember/runloop"; import { service } from "@ember/service"; import DiscourseRoute from "discourse/routes/discourse"; import Topic from "discourse/models/topic"; export default class TopicRoute extends DiscourseRoute { @service composer; @service screenTrack; @service currentUser; @service router; queryParams = { filter: { replace: true }, username_filters: { replace: true }, }; async model(params) { const topic = await Topic.find(params.id, { page: params.page, post_number: params.post_number, }); return topic; } afterModel(model) { if (!model.can_see) { this.router.replaceWith("/404"); return; } this.screenTrack.start(model.id); } setupController(controller, model) { super.setupController(controller, model); controller.setProperties({ model: model, editingTopic: false, }); } @action showTopicInvite(topic) { this.modal.show(TopicInviteModal, { model: { topic } }); } @action didTransition() { this.controllerFor("topic").send("readPosts"); return true; } } // File: frontend/discourse/app/routes/discovery.js import DiscourseRoute from "discourse/routes/discourse"; import { service } from "@ember/service"; export default class DiscoveryRoute extends DiscourseRoute { @service router; @service currentUser; queryParams = { order: { refreshModel: true }, ascending: { refreshModel: true }, }; async model(params) { const category = params.category_slug_path_with_id ? await this.store.findCategory(params.category_slug_path_with_id) : null; const list = await this.store.findFiltered("topicList", { filter: params.filter || "latest", params: { category: category?.id, order: params.order, ascending: params.ascending, }, }); return { category: category, list: list, }; } titleToken() { const filterText = this.get("filterMode") || "topics"; return filterText.capitalize(); } } // Usage in Templates // File: frontend/discourse/app/templates/topic.hbs
// Router Configuration // File: frontend/discourse/app/router.js Router.map(function () { this.route("topic", { path: "/t/:slug/:id" }); this.route("topicBySlugOrId", { path: "/t/:slugOrId" }); this.route("discovery", { path: "/" }, function () { this.route("categories"); this.route("latest"); this.route("new"); this.route("unread"); this.route("top"); this.route("category", { path: "/c/:category_slug_path_with_id" }); }); this.route("user", { path: "/u/:username" }, function () { this.route("summary"); this.route("activity"); this.route("notifications"); this.route("preferences"); }); }); ``` ### Models - Data Layer ActiveRecord models represent database entities with relationships, validations, and business logic. ```ruby # File: app/models/post.rb class Post < ActiveRecord::Base include RateLimiter::OnCreateRecord include Trashable include Searchable BAKED_VERSION = 2 belongs_to :user belongs_to :topic belongs_to :reply_to_user, class_name: "User" has_many :post_replies has_many :replies, through: :post_replies has_many :post_actions, dependent: :destroy has_many :bookmarks, as: :bookmarkable has_many :post_revisions validates :raw, presence: true validates :user_id, presence: true before_save :cook_raw after_commit :index_search scope :public_posts, -> { where(post_type: types[:regular]) } scope :visible, -> { where(hidden: false) } scope :by_newest, -> { order(created_at: :desc) } def cook_raw self.cooked = PrettyText.cook(raw) end def excerpt(max_length = 220) PrettyText.excerpt(cooked, max_length, text_entities: true) end def url "/t/#{topic.slug}/#{topic.id}/#{post_number}" end def self.types @types ||= Enum.new( regular: 1, moderator_action: 2, small_action: 3, whisper: 4 ) end end # File: app/models/topic.rb class Topic < ActiveRecord::Base include Searchable include Trashable belongs_to :category belongs_to :user has_many :posts, -> { order(:post_number) } has_many :topic_allowed_users has_many :allowed_users, through: :topic_allowed_users has_many :topic_tags has_many :tags, through: :topic_tags validates :title, presence: true, length: { in: 1..255 } validates :user_id, presence: true scope :visible, -> { where(visible: true) } scope :public_topics, -> { where(archetype: "regular") } scope :private_messages, -> { where(archetype: "private_message") } def self.featured visible .where(pinned_at: nil) .order(bumped_at: :desc) .limit(5) end def update_statistics update_columns( posts_count: posts.count, reply_count: posts.count - 1, last_posted_at: posts.maximum(:created_at) ) end def featured_users User.where( id: posts.select(:user_id).distinct.limit(5) ) end end # File: app/models/user.rb class User < ActiveRecord::Base include Searchable has_many :posts has_many :topics has_many :user_emails has_one :user_profile, dependent: :destroy has_one :user_option, dependent: :destroy has_many :group_users has_many :groups, through: :group_users validates :username, presence: true, uniqueness: true validates :username, format: { with: /\A[a-zA-Z0-9_]+\z/ } has_secure_password scope :activated, -> { where(active: true) } scope :staff, -> { where("admin OR moderator") } def staff? admin? || moderator? end def trust_level TrustLevel.levels[self[:trust_level]] end def in_any_groups?(group_ids) groups.where(id: group_ids).exists? end end # Usage Examples # Create a post post = Post.create!( user: current_user, topic_id: 123, raw: "Post content here", post_type: Post.types[:regular] ) # Query with eager loading (N+1 prevention) posts = Post.public_posts .visible .includes(:user, :topic, :bookmarks) .where(topic_id: topic_ids) .order(created_at: :desc) .limit(20) # Bulk operations Post.where(topic_id: old_topic.id).update_all(topic_id: new_topic.id) # Find with conditions user = User.activated.find_by(username: "john_doe") topics = Topic.visible.where(category_id: 5).by_bumped ``` ### Plugin Architecture - Extensibility System Plugins extend Discourse functionality with isolated, reusable modules. ```ruby # File: plugins/discourse-solved/plugin.rb # frozen_string_literal: true # name: discourse-solved # about: Allows users to accept solutions on topics # version: 0.1 # authors: Sam Saffron # url: https://github.com/discourse/discourse/tree/main/plugins/discourse-solved enabled_site_setting :solved_enabled register_asset "stylesheets/solutions.scss" register_svg_icon "check-square" module ::DiscourseSolved PLUGIN_NAME = "discourse-solved" end require_relative "lib/discourse_solved/engine" after_initialize do module ::DiscourseSolved def self.accept_answer!(post, acting_user, topic: nil) topic ||= post.topic ActiveRecord::Base.transaction do # Remove previous solution if exists if previous = topic.custom_fields["accepted_answer_post_id"] UserAction.where( action_type: UserAction::SOLVED, target_post_id: previous ).destroy_all end # Mark new solution topic.custom_fields["accepted_answer_post_id"] = post.id topic.save_custom_fields # Log user action UserAction.log_action!( action_type: UserAction::SOLVED, user_id: post.user_id, acting_user_id: acting_user.id, target_post_id: post.id, target_topic_id: topic.id ) # Grant badge if configured if SiteSetting.solved_grant_badge BadgeGranter.grant( Badge.find(SiteSetting.solved_badge_id), post.user ) end end MessageBus.publish("/topic/#{topic.id}", reload_topic: true) { success: true, post_id: post.id } end end # Extend existing classes TopicView.class_eval do def accepted_answer_post_id @topic.custom_fields["accepted_answer_post_id"]&.to_i end end Post.class_eval do def is_accepted_answer? id == topic&.custom_fields&.dig("accepted_answer_post_id")&.to_i end end # Add to serializers TopicViewSerializer.class_eval do attributes :accepted_answer def accepted_answer { post_id: object.accepted_answer_post_id, username: accepted_answer_post&.username, excerpt: accepted_answer_post&.excerpt } end def include_accepted_answer? object.accepted_answer_post_id.present? end private def accepted_answer_post @accepted_answer_post ||= object.posts.find_by( id: object.accepted_answer_post_id ) end end end # Plugin Routes # File: plugins/discourse-solved/config/routes.rb DiscourseSolved::Engine.routes.draw do post "/accept" => "answer#accept" post "/unaccept" => "answer#unaccept" end Discourse::Application.routes.draw do mount DiscourseSolved::Engine, at: "solution" end # Plugin Controller # File: plugins/discourse-solved/app/controllers/discourse_solved/answer_controller.rb module DiscourseSolved class AnswerController < ::ApplicationController requires_plugin DiscourseSolved::PLUGIN_NAME def accept post = Post.find(params[:id]) guardian.ensure_can_accept_answer!(post.topic, post) result = DiscourseSolved.accept_answer!(post, current_user) render json: result end def unaccept post = Post.find(params[:id]) guardian.ensure_can_accept_answer!(post.topic, post) topic = post.topic topic.custom_fields.delete("accepted_answer_post_id") topic.save_custom_fields render json: success_json end end end # Plugin Guardian Extension # File: plugins/discourse-solved/lib/guardian_extension.rb module DiscourseSolved::GuardianExtension def can_accept_answer?(topic, post) return false unless SiteSetting.solved_enabled return false unless topic.present? && post.present? return true if is_staff? # Topic owner can accept answers return true if authenticated? && topic.user_id == @user.id # High trust level users can accept if configured trust_level = SiteSetting.accept_all_solutions_trust_level authenticated? && @user.has_trust_level?(trust_level) end end Guardian.class_eval do include DiscourseSolved::GuardianExtension end # API Usage # POST /solution/accept.json curl -X POST "https://discourse.example.com/solution/accept.json" \ -H "Api-Key: YOUR_API_KEY" \ -H "Api-Username: username" \ -H "Content-Type: application/json" \ -d '{"id": 456}' # Response { "success": true, "post_id": 456 } ``` ### Serializers - JSON Response Formatting Serializers control JSON output structure and what data is exposed via APIs. ```ruby # File: app/serializers/post_serializer.rb class PostSerializer < BasicPostSerializer attributes :post_number, :post_type, :updated_at, :reply_count, :reply_to_post_number, :quote_count, :incoming_link_count, :reads, :score, :topic_id, :topic_slug, :topic_title, :category_id, :display_username, :version, :can_edit, :can_delete, :can_recover, :user_title, :raw, :actions_summary, :moderator, :admin, :staff, :user_id, :hidden, :trust_level, :deleted_at, :user_deleted, :edit_reason, :wiki has_one :user_custom_fields, serializer: UserCustomFieldsSerializer def user_custom_fields object.user&.custom_fields end def include_user_custom_fields? return false if object.user.nil? scope.can_edit?(object.user) end def can_edit scope.can_edit?(object) end def can_delete scope.can_delete_post?(object) end def moderator object.user&.moderator? end def admin object.user&.admin? end def include_raw? return true if scope.is_staff? return true if scope.user&.id == object.user_id false end def topic_slug object.topic&.slug end def actions_summary PostAction .counts_for([object], scope.user) .fetch(object.id, []) end end # File: app/serializers/topic_view_serializer.rb class TopicViewSerializer < ApplicationSerializer attributes :id, :title, :fancy_title, :posts_count, :created_at, :views, :reply_count, :like_count, :last_posted_at, :visible, :closed, :archived, :archetype, :slug, :category_id, :pinned_globally, :pinned_at, :tags has_one :details, serializer: TopicViewDetailsSerializer has_many :posts, serializer: PostSerializer has_many :suggested_topics, serializer: SuggestedTopicSerializer def details object end def tags object.tags.pluck(:name) end def include_tags? SiteSetting.tagging_enabled end def posts object.posts.includes(:user, :topic, :post_actions) end def suggested_topics object.suggested_topics&.topics || [] end end # Usage in Controller class PostsController < ApplicationController def show post = Post.includes(:user, :topic).find(params[:id]) render_serialized(post, PostSerializer, scope: guardian, root: "post") end def index posts = Post.where(topic_id: params[:topic_id]) .includes(:user, :topic) .order(:post_number) render_serialized(posts, PostSerializer, scope: guardian) end end # Custom Serializer Example class CustomPostSerializer < PostSerializer attributes :custom_field, :computed_value def custom_field object.custom_fields["my_custom_field"] end def computed_value "#{object.user.username} at #{object.created_at.strftime('%Y-%m-%d')}" end def include_computed_value? scope.authenticated? end end # JSON Output Example { "post": { "id": 456, "post_number": 2, "post_type": 1, "updated_at": "2025-01-15T10:30:00.000Z", "reply_count": 3, "reply_to_post_number": 1, "quote_count": 1, "reads": 45, "topic_id": 123, "topic_slug": "how-to-use-discourse-api", "topic_title": "How to use Discourse API", "category_id": 5, "display_username": "john_doe", "can_edit": true, "can_delete": false, "user_title": "Regular User", "raw": "This is the post content...", "moderator": false, "admin": false, "trust_level": 2, "actions_summary": [ { "id": 2, "count": 5, "acted": false } ] } } ``` ## Summary and Integration Discourse provides a comprehensive REST API for all core operations including posts, topics, users, categories, and more. The API follows RESTful conventions with JSON responses, supports authentication via API keys or user sessions, and implements fine-grained authorization through the Guardian system. Controllers delegate business logic to service objects, maintain thin responsibilities, and leverage ActiveRecord models with proper eager loading to prevent N+1 queries. The platform uses serializers to control JSON output and expose only authorized data based on user permissions. The frontend Ember.js application communicates with the Rails backend through well-defined API endpoints, utilizing routes for navigation, components for UI composition, and services for shared state management. The plugin architecture allows extending functionality at multiple layers - models, controllers, serializers, routes, and frontend components - enabling developers to add features without modifying core code. Site settings provide runtime configuration accessible in both Ruby and JavaScript contexts, making the platform highly customizable. This architecture supports multi-site deployments, real-time features via MessageBus, background job processing with Sidekiq, and comprehensive testing with RSpec and QUnit, making it suitable for communities of all sizes.