mercado_publico_cl
Cliente Ruby para la API pública de Mercado Público (Chile).
Gema Ruby para consumir la API de compras públicas del Estado de Chile (Mercado Público): licitaciones, órdenes de compra, proveedores y organismos públicos. El código y la API pública de la librería están en inglés; la documentación está en español.
Tabla de contenidos
- Instalación
- ¿Qué es Mercado Público?
- Obtener tu ticket
- Configuración
- Uso rápido
- Seguridad
- Listados vs detalle:
summary? - Licitaciones (Tenders)
- Órdenes de compra (Purchase Orders)
- Proveedores (Suppliers)
- Organismos públicos (Public Agencies)
- Anexos: tablas de códigos
- Manejo de errores
- Sincronización con tu base de datos
- Acceso al payload original
- Testing en tu app
- Limitaciones y notas
- Contribuciones
- Licencia
Instalación
Agrega la gema a tu Gemfile:
gem "mercado_publico_cl"
O instálala manualmente:
gem install mercado_publico_cl
Requisitos: Ruby >= 3.2 (probado en 3.2, 3.3, 3.4 y 4.0).
¿Qué es Mercado Público?
Mercado Público es la plataforma electrónica de compras públicas del Estado de Chile, operada por la Dirección ChileCompra (https://www.mercadopublico.cl). A través de su API pública es posible consultar licitaciones, órdenes de compra, proveedores y organismos compradores.
Obtener tu ticket
Cada usuario o aplicación necesita un ticket (token de acceso) para consumir la API. Para obtenerlo:
- Ingresa a https://www.mercadopublico.cl con tu Clave Única o credenciales de proveedor/organismo.
- Ingresa a tu Escritorio y selecciona Servicios Web.
- Genera un ticket nuevo. Guardalo de forma segura (no lo subas a tu repo).
El ticket es personal. No lo compartas en commits ni en logs.
Configuración
En Rails (con el generator)
rails g mercado_publico_cl:install
Esto crea config/initializers/mercado_publico_cl.rb con todas las
opciones documentadas. Editalo y exporta tu ticket vía ENV.
En Rails (manual)
config/initializers/mercado_publico_cl.rb:
MercadoPublicoCl.configure do |c|
c.ticket = ENV.fetch("MERCADO_PUBLICO_TICKET")
c.timeout = 10
c.logger = Rails.logger
c.base_url = "https://api.mercadopublico.cl"
end
Fuera de Rails
require "mercado_publico_cl"
MercadoPublicoCl.configure do |c|
c.ticket = ENV.fetch("MERCADO_PUBLICO_TICKET")
end
Variables disponibles
| Variable | Tipo | Default | Descripción |
|---|---|---|---|
ticket |
String | ENV["MERCADO_PUBLICO_TICKET"] |
Token de acceso |
timeout |
Integer | 10 |
Timeout HTTP en segundos |
logger |
Logger / nil | nil |
Si se setea, registra cada request (sin el ticket) |
base_url |
String | https://api.mercadopublico.cl |
Solo https (se permite http únicamente hacia localhost, para mocks) |
time_zone |
String | "America/Santiago" |
Zona horaria para la fecha default de las consultas |
max_retries |
Integer | 0 |
Reintentos automáticos en errores transitorios |
retry_base |
Float | 0.5 |
Segundos de la primera espera (se duplica con backoff) |
retry_max_wait |
Integer | 30 |
Cap del backoff |
retry_on |
Array |
[:rate_limit, :server_error, :timeout] |
Categorías reintentables (acepta strings, se normalizan) |
MercadoPublicoCl.configuration devuelve la instancia actual. MercadoPublicoCl.reset!
la reinicia (útil en specs).
Fechas en hora de Chile
La API indexa por fecha chilena. Cuando una consulta necesita fecha y no
le pasas una, la gema usa MercadoPublicoCl.today: el "hoy" calculado en
America/Santiago (vía TZInfo si está disponible — Rails lo trae — o con
offset fijo -04:00 como fallback). Esto evita que un worker corriendo en
UTC consulte, durante la noche chilena, un día que aún no tiene datos.
MercadoPublicoCl.today # => Date del día actual en Chile
MercadoPublicoCl::Tender.where(status: :active) # usa esa fecha por defecto
Reintentos automáticos
Por defecto los reintentos están desactivados (max_retries = 0). Para
producción se recomienda activarlos:
MercadoPublicoCl.configure do |c|
c.max_retries = 3
c.retry_base = 0.5
c.retry_max_wait = 30
c.retry_on = %i[rate_limit server_error timeout]
end
- Backoff exponencial con jitter: primer reintento ~
retry_bases, segundo ~retry_base * 2s, tercero ~retry_base * 4s, todo capeado enretry_max_wait. Retry-After: si la API devuelve un 429 con headerRetry-After: N, se respeta ese valor (capeado enretry_max_wait; también disponible enRateLimitError#retry_after).- Errores de red incluidos: conexiones rechazadas o reseteadas
(
ECONNREFUSED,ECONNRESET, SSL, EOF...) se traducen aApiErrory se reintentan bajo la categoría:server_error. - Lo que NO se reintenta:
InvalidTicketError(401/403),NotFoundError(404),InvalidQueryError,ApiError4xx y redirecciones 3xx. Reintentar errores deterministas solo retrasa el feedback. - Logging: cada reintento se loguea como
WARNsi hayloggerconfigurado.
Uso rápido
tender = MercadoPublicoCl::Tender.find("1057-25-LE25")
tender.status # => :published
tender.estimated_amount # => 1500000.0
orders = MercadoPublicoCl::PurchaseOrder.where(status: :accepted) # hoy (hora chilena)
orders.each { |o| puts o.code }
supplier = MercadoPublicoCl::Supplier.find_by_rut("70.017.820-k")
agencies = MercadoPublicoCl::PublicAgency.all
Listados vs detalle: summary?
La API de Mercado Público devuelve dos shapes distintos según cómo consultes:
| Método | Qué devuelve | Campos por item |
|---|---|---|
Tender.all / .active / .where(...) |
Listado liviano | 3-4 (code, name, status, closing_date) |
Tender.find(code) |
Detalle completo | 60+ (items, fechas, comprador, etc.) |
PurchaseOrder.all / .where(...) |
Listado liviano | 3 (code, name, status) |
PurchaseOrder.find(code) |
Detalle completo | 60+ |
Esto no es una decisión de la gema — es así como responde el server. Probablemente por performance: un día puede tener 4.500 licitaciones activas o 18.000 OCs. El detalle completo de todas sería de varios MB.
¿Por qué importa?
Si llamas .active y lees un campo que no viene en el resumen, te va a
devolver nil — pero no significa que el dato no exista, solo que
no lo pediste:
t = MercadoPublicoCl::Tender.active.first
t.code # => "1000-8-LE26" ✓ vino en el resumen
t.complaints_count # => nil ✗ no vino — pero la licitación SÍ tiene reclamos
t.items # => [] ✗ no vino — pero la licitación SÍ tiene items
¿Cómo distingo uno del otro?
Con summary?:
t = MercadoPublicoCl::Tender.active.first
t.summary? # => true → solo tienes el resumen
t = MercadoPublicoCl::Tender.find("1000-8-LE26")
t.summary? # => false → tienes el detalle completo
Patrón: hidratar listado con detalle
Si quieres trabajar con el detalle completo de cada item, hay que pedirlo:
codes = MercadoPublicoCl::Tender.active.map(&:code) # 1 request
details = codes.map { |c| MercadoPublicoCl::Tender.find(c) } # 1 request por cada
⚠️ Cuidado con el límite de 10.000 requests/día por ticket. Hidratar
los items de un listado se llama "1+N requests": una por el listado más
una por cada detalle. Para 4.500 licitaciones activas son 4.501
requests — casi medio cupo diario gastado en una operación. Filtra lo
más posible (agency_code, supplier_code, status) antes de hidratar.
Licitaciones (Tenders)
Consultas disponibles
| Endpoint de la API real | Método de la gema |
|---|---|
GET /servicios/v1/publico/licitaciones.json?codigo=CODE |
Tender.find(code) |
GET /servicios/v1/publico/licitaciones.json?estado=todos |
Tender.all |
GET /servicios/v1/publico/licitaciones.json?estado=activas |
Tender.active |
GET ...?fecha=ddmmYYYY |
Tender.where(date:) |
GET ...?estado=adjudicada |
Tender.where(status: :awarded) |
GET ...?fecha=ddmmYYYY&estado=adjudicada |
Tender.where(date:, status: :awarded) |
GET ...?fecha=ddmmYYYY&proveedor=17793 |
Tender.where(date:, supplier_code: 17793) |
GET ...?fecha=ddmmYYYY&CodigoOrganismo=6945 |
Tender.where(date:, agency_code: 6945) |
Estados
| Código | Símbolo | Descripción |
|---|---|---|
| 5 | :published |
Publicada |
| 6 | :closed |
Cerrada |
| 7 | :deserted |
Desierta |
| 8 | :awarded |
Adjudicada |
| 18 | :revoked |
Revocada |
| 19 | :suspended |
Suspendida |
| — | :active |
Atajo estado=activas |
| — | :all |
Atajo estado=todos |
Atributos del objeto Tender
Nota:
Tender.all/.active/.wheredevuelven resúmenes (code,name,status,closing_date). El detalle completo solo aparece llamando aTender.find(code). Usatender.summary?para detectar uno u otro.
Top-level
| Atributo | Tipo | Descripción |
|---|---|---|
code |
String | CodigoExterno |
name |
String | Nombre |
description |
String | Descripcion |
status / status_code |
Symbol / Int | Estado |
tender_type / tender_type_code |
Symbol / String | Tipo (L1, LE, LP, ...) |
currency / currency_code |
Symbol / String | Moneda |
estimated_amount |
Float | MontoEstimado |
estimated_amount_type / _code |
Symbol / Int | Presupuesto o referencia |
tender_payment_method / _code |
Symbol / Int | Modalidad de pago |
evaluation_time_unit / _code |
Symbol / Int | Unidad de evaluación |
contract_duration_time_unit / _code |
Symbol / Int | Unidad duración contrato |
administrative_act / _code |
Symbol / Int | Tipo de acto |
supplier_code |
Integer | CodigoProveedor |
agency_code |
Integer | CodigoOrganismo |
creation_date |
Date | FechaCreacion |
closing_date |
Date | FechaCierre |
publication_date |
Date | FechaPublicacion |
award_date |
Date | FechaAdjudicacion |
informed? |
Boolean | Informada (1=Sí, 0=No) |
public? / private? |
Boolean | CodigoTipo |
requires_contraloria? |
Boolean | TomaRazon |
public_technical_offers? |
Boolean | EstadoPublicidadOfertas |
has_contract? |
Boolean | Contrato (doc oficial: 1=Sí, 0=No; solo 1 es true) |
public_works? |
Boolean | Obras (¡1=No, 2=Sí!) |
visible_amount? |
Boolean | VisibilidadMonto |
allows_subcontracting? |
Boolean | SubContratacion |
extends_deadline? |
Boolean | ExtensionPlazo |
template_based? |
Boolean | EsBaseTipo |
renewable? |
Boolean | EsRenovable |
complaints_count |
Integer | CantidadReclamos |
days_to_close |
Integer | DiasCierreLicitacion |
funding_source |
String | FuenteFinanciamiento |
contracting_restriction |
String | ProhibicionContratacion |
bip_code |
String / nil | CodigoBIP |
raw |
Hash | JSON original sin tocar |
Objetos anidados
| Atributo | Clase | Acceso a campos |
|---|---|---|
buyer |
TenderBuyer |
buyer.code, buyer.name, buyer.unit_*, buyer.user_name, buyer.user_role, ... |
items |
Array<TenderItem> |
items.first.product_name, .quantity, .unit_of_measure, .award |
dates |
TenderDates |
dates.creation, .closing, .publication, .award, .technical_opening, .economic_opening, .estimated_award, .site_visit, .documents_delivery, ... (16 fechas) |
award |
TenderAward / nil |
award.resolution_number, .resolution_date, .award_type_code, .bidders_count, .act_url (URL del acta; solo cuando está adjudicada) |
payment_contact |
TenderContact / nil |
payment_contact.name, .email |
contract_contact |
TenderContact / nil |
contract_contact.name, .email, .phone |
Shortcuts (back-compat con la API anterior)
tender.agency_code y tender.agency_name redirigen a tender.buyer.
tender.creation_date, closing_date, publication_date, award_date
redirigen a tender.dates.
Proveedor adjudicado
El proveedor ganador viene en la Adjudicacion de cada ítem, no en la
de nivel licitación. La gema lo expone directo:
t = MercadoPublicoCl::Tender.find("1219241-3-LR26")
t.awarded? # => true
t.awarded_supplier_rut # => "97.006.000-6"
t.awarded_supplier_name # => "BANCO DE CREDITO E INVERSIONES"
t.award.act_url # => URL del acta de adjudicación
Ejemplos detallados
# Buscar por código
tender = MercadoPublicoCl::Tender.find("1057-25-LE25")
tender.public_works? # => true
tender.raw["Items"] # acceso al payload original
# Todas las licitaciones publicadas hoy (hora chilena, fecha implícita)
MercadoPublicoCl::Tender.where(status: :published)
# Filtrar por organismo + día
MercadoPublicoCl::Tender.where(date: MercadoPublicoCl.today, agency_code: 6945)
Órdenes de compra (Purchase Orders)
Consultas disponibles
| Endpoint API real | Método de la gema |
|---|---|
GET /servicios/v1/publico/ordenesdecompra.json?codigo=CODE |
PurchaseOrder.find(code) |
GET ...?estado=todos |
PurchaseOrder.all |
GET ...?fecha=ddmmYYYY[&estado=...&proveedor=...&CodigoOrganismo=...] |
PurchaseOrder.where(...) |
Estados
| Código | Símbolo | Descripción |
|---|---|---|
| 4 | :sent_to_supplier |
Enviada a proveedor |
| 5 | :in_process |
En proceso (solo lectura — la API no lo acepta como filtro) |
| 6 | :accepted |
Aceptada |
| 9 | :cancelled |
Cancelada |
| 12 | :received |
Recepción conforme |
| 13 | :pending_receipt |
Pendiente recepción |
| 14 | :partially_received |
Recepción parcial |
| 15 | :incomplete_receipt |
Recepción conforme incompleta |
| — | :all |
Atajo estado=todos |
supplier_status(estado del lado del proveedor) usa una tabla de códigos distinta — ver Estado de OC según proveedor en los anexos.
Atributos del objeto PurchaseOrder
Nota:
PurchaseOrder.all/.wheredevuelven resúmenes con solocode,name,status. El detalle completo aparece conPurchaseOrder.find(code). Usaoc.summary?para distinguirlos.
Top-level
| Atributo | Tipo | Descripción |
|---|---|---|
code |
String | Codigo |
name, description |
String | |
status / status_code / status_label |
Symbol / Int / String | Estado de la OC |
supplier_status / _code / _label |
Symbol / Int / String | Estado según proveedor |
tender_code |
String / nil | CodigoLicitacion — licitación de origen |
purchase_order_type / _code / _label |
Symbol / Int / String | Tipo |
currency / currency_code |
Symbol / String | |
total_amount |
Float | Total (con IVA) |
net_amount |
Float | TotalNeto |
tax_amount |
Float | Impuestos |
iva_percentage |
Float | PorcentajeIva |
discount_amount, charge_amount |
Float | Descuentos, Cargos |
dispatch_type / _code |
Symbol / Int | |
payment_type / _code |
Symbol / Int | |
funding_source |
String | Financiamiento |
country |
String | Pais |
has_items? |
Boolean | TieneItems |
rating |
Float | PromedioCalificacion |
rating_count |
Integer | CantidadEvaluacion |
raw |
Hash | JSON original |
Objetos anidados
| Atributo | Clase | Campos clave |
|---|---|---|
buyer |
POBuyer |
code, name, unit_rut, unit_name, unit_comuna, activity, contact |
vendor |
POVendor |
code, name, branch_rut, branch_name, address, comuna, region, contact |
items |
Array<POItem> |
product_name, quantity, unit_price, total, currency |
dates |
PODates |
creation, sent, acceptance, cancellation, last_modified |
Shortcuts
supplier_code, supplier_name, agency_code, agency_name, creation_date,
sent_date, acceptance_date, cancellation_date, last_modified_at redirigen
a los objetos anidados correspondientes.
Ejemplos
order = MercadoPublicoCl::PurchaseOrder.find("1057-25-SE25")
order.status # => :accepted
order.total_amount # => 250000.0
MercadoPublicoCl::PurchaseOrder.where(date: MercadoPublicoCl.today, supplier_code: 17_793)
Proveedores (Suppliers)
Buscar por RUT
supplier = MercadoPublicoCl::Supplier.find_by_rut("70.017.820-k")
supplier.name # => "Proveedor Demo SpA"
supplier.code # => 12345
Atributos: code, name, rut, raw.
El RUT se acepta con o sin puntos (siempre con guión y dígito
verificador): "70.017.820-k" y "70017820-k" son equivalentes — la gema
lo canonicaliza con puntos antes de consultar. Formatos no reconocibles
levantan InvalidQueryError.
Organismos públicos (Public Agencies)
MercadoPublicoCl::PublicAgency.all # listado completo (899 organismos)
MercadoPublicoCl::PublicAgency.find(6945) # filtra por código (ver advertencia abajo)
MercadoPublicoCl::PublicAgency.directory # Hash code→agency, ideal para múltiples lookups
Atributos: code, name, raw.
⚠️ Advertencia de performance en PublicAgency.find
La API no expone un endpoint "buscar organismo por código". Por eso find
internamente hace un .all (request HTTP con los 899 organismos) y filtra
en memoria — una request por cada llamada a find.
Si necesitas resolver varios códigos, usa .directory que devuelve un
Hash y hace una sola request:
# ❌ Mal: 10 lookups = 10 requests con 899 items cada uno
codes = [7086, 1224636, 7193, 7212, 1824441, 1806837, 6959, 7265, 7038, 7313]
agencies = codes.map { |c| MercadoPublicoCl::PublicAgency.find(c) }
# ✓ Bien: 1 request, lookups O(1)
directory = MercadoPublicoCl::PublicAgency.directory
agencies = codes.map { |c| directory[c] }
Si ya tienes la lista en memoria, puedes saltarte la request:
list = MercadoPublicoCl::PublicAgency.all
directory = MercadoPublicoCl::PublicAgency.directory(list: list)
Esto importa especialmente con el límite de 10.000 requests/día.
Anexos: tablas de códigos
Tipos de licitación
| Código | Símbolo |
|---|---|
| L1 | :public_tender_under_100_utm |
| LE | :public_tender_100_to_1000_utm |
| LP | :public_tender_1000_to_2000_utm |
| LQ | :public_tender_2000_to_5000_utm |
| LR | :public_tender_over_5000_utm |
| LS | :personal_services_tender |
| A1/B1/J1/F1/E1 | variantes (ver Enums::TenderType) |
| CO/B2/A2/E2/H2/I2 | privadas |
| D1/C1/C2/F2/F3 | obras públicas |
| G1/G2 | concesión |
| R1 | otra |
| CA | compra coordinada |
| SE | trato directo / selección |
Tipos de orden de compra
| Código | Símbolo | Etiqueta API |
|---|---|---|
| 1 | :automatic |
OC |
| 2 | :d1 |
D1 |
| 3 | :c1 |
C1 |
| 4 | :f3 |
F3 |
| 5 | :g1 |
G1 |
| 6 | :r1 |
R1 |
| 7 | :ca |
CA |
| 8 | :se |
SE |
| 9 | :framework_agreement |
CM |
| 10 | :fg |
FG |
| 11 | :tl |
TL |
| 12 | :microcompra |
MC |
| 13 | :compra_agil |
AG |
| 14 | :coordinated_purchase |
CC |
Monedas
| Código | Símbolo |
|---|---|
| CLP | :clp |
| CLF | :clf |
| USD | :usd |
| UTM | :utm |
| EUR | :eur |
Modalidad de pago (licitación)
| Código | Símbolo |
|---|---|
| 1 | :net_30 |
| 2 | :net_30_60_90 |
| 3 | :same_day |
| 4 | :annual |
| 5 | :net_60 |
| 6 | :monthly |
| 7 | :on_delivery |
| 8 | :bimonthly |
| 9 | :milestone_based |
| 10 | :quarterly |
Modalidad de pago (orden de compra)
| Código | Símbolo |
|---|---|
| 1 | :net_15_on_invoice |
| 2 | :net_30_on_invoice |
| 39 | :other |
| 46 | :net_50_on_invoice |
| 47 | :net_60_on_invoice |
| 48 | :net_45 |
| 49 | :over_30_days |
Tipo de despacho
| Código | Símbolo |
|---|---|
| 7 | :to_address |
| 9 | :per_program |
| 12 | :other |
| 14 | :pickup_from_warehouse |
| 20 | :air_courier |
| 21 | :ground_courier |
| 22 | :to_be_agreed |
Unidad de tiempo
| Código | Símbolo |
|---|---|
| 1 | :hours |
| 2 | :days |
| 3 | :weeks |
| 4 | :months |
| 5 | :years |
Acto administrativo
| Código | Símbolo |
|---|---|
| 1 | :authorization |
| 2 | :resolution |
| 3 | :other |
| 4 | :decree |
| 5 | :agreement |
Estado de OC según proveedor
CodigoEstadoProveedor / EstadoProveedor usa su propia tabla, distinta a
la del estado de la orden ("Recepción Conforme" es 12 para la OC pero 7
para el proveedor). La tabla no está documentada oficialmente; estos pares
se confirmaron contra la API real (jun-2026). Códigos no confirmados
devuelven nil en supplier_status — el código y el label crudos siempre
están en supplier_status_code / supplier_status_label.
| Código | Símbolo | Label de la API |
|---|---|---|
| 1 | :new_order |
Nueva orden de compra |
| 4 | :accepted |
Aceptada |
| 5 | :cancelled |
Cancelada |
| 7 | :received |
Recepción Conforme |
Tipo de monto estimado
| Código | Símbolo |
|---|---|
| 1 | :available_budget |
| 2 | :reference_price |
| 3 | :not_estimable |
Campos binarios
La API retorna 0/1 para la mayoría de los flags. Una excepción importante
es Obras donde 1=No y 2=Sí — la gema invierte esto en public_works?.
Manejo de errores
Toda la jerarquía cuelga de MercadoPublicoCl::Error.
| Error | Cuándo |
|---|---|
ConfigurationError |
Problema de configuración |
MissingTicketError |
Se llama a un recurso sin ticket configurado |
InvalidTicketError |
HTTP 401/403 o Codigo 401/403 |
NotFoundError |
HTTP 404 o Codigo 404 |
InvalidQueryError |
Combinación de parámetros inválida |
RateLimitError |
HTTP 429 o Codigo 429 |
TimeoutError |
Timeout de socket o HTTP 408 |
ApiError |
Cualquier 5xx o Codigo no manejado. Trae status_code y response_body |
Códigos con formato inválido lanzan ApiError (HTTP 500)
La API de Mercado Público responde con HTTP 500 cuando recibe un
codigo con formato inválido (caracteres no esperados, shape distinto al
documentado, etc.), en vez de devolver 400/404. Esto se traduce a
ApiError en la gema.
Cuando el código tiene el formato correcto pero no existe, la API
devuelve un listado vacío y find retorna nil. Resumen:
| Caso | Comportamiento de find |
|---|---|
| Código bien formado y existe | Devuelve el objeto |
| Código bien formado y no existe | Devuelve nil |
| Código con formato inválido | Lanza ApiError(status_code: 500) |
La gema no pre-valida el formato del código intencionalmente, porque el shape exacto varía entre tipos de recurso y podría cambiar. La responsabilidad de validar el input recae en el consumidor:
begin
tender = MercadoPublicoCl::Tender.find(user_input)
render_tender(tender) if tender
rescue MercadoPublicoCl::ApiError => e
if e.status_code == 500
render_error("Código con formato inválido")
else
raise
end
end
O directamente validando antes de llamar:
unless user_input.match?(/\A\d+-\d+-[A-Z]+\d+\z/i)
return render_error("Formato esperado: organismo-número-tipoAÑO")
end
MercadoPublicoCl::Tender.find(user_input)
Sincronización con tu base de datos
Si tu app corre ActiveJob, puedes subclasear los jobs incluidos:
class TenderSyncJob < MercadoPublicoCl::Jobs::SyncTenders
queue_as :default
def process(tender)
Tender.upsert!(
code: tender.code,
status: tender.status,
payload: tender.raw
)
end
end
# Programar un sync diario — sin `date:` usa el "hoy" chileno,
# evaluado al EJECUTAR el job (no al encolarlo)
TenderSyncJob.perform_later(status: :published)
Evita
perform_later(date: Date.today): esa fecha se evalúa al encolar y con la TZ del server. Si el job corre después de medianoche (o el server está en UTC), sincronizarías el día equivocado.
La gema no toca tu modelo de DB — solo orquesta. Lo mismo aplica para
MercadoPublicoCl::Jobs::SyncPurchaseOrders.
Acceso al payload original
Cada recurso expone .raw con el Hash JSON sin transformar, por si necesitas
campos que la gema no mapea:
tender = MercadoPublicoCl::Tender.find("1057-25-LE25")
tender.raw["Items"]
tender.raw["Unidades"]
Testing en tu app
La gema usa HTTParty internamente. Para stubear las requests en tus specs puedes usar WebMock:
stub_request(:get, %r{api\.mercadopublico\.cl/.+/licitaciones\.json})
.to_return(status: 200, body: { "Listado" => [] }.to_json,
headers: { "Content-Type" => "application/json" })
Seguridad
El ticket es una credencial — tratala como tal
- No lo subas al repositorio — usa
ENVo tu password manager. La generación del initializer ya lo asume. - No lo registres en logs. La gema redacta el ticket de
inspect/to_sdeConfigurationy delresponse_bodyde cualquierApiError. Aun así, no lo incluyas en cadenas que registres tú mismo. - No lo compartas entre ambientes. Mercado Público te permite generar varios tickets — uno por ambiente (dev/staging/prod).
Redirecciones HTTP deshabilitadas
La gema configura HTTParty con no_follow: true. Si el servidor responde
con un 3xx, la gema no sigue el redirect automáticamente — lanza
ApiError para que investigues. Esto evita un ataque MITM donde un
upstream comprometido podría redirigir el request a otro host.
Mercado Público no usa redirects en producción, así que esto no afecta el funcionamiento normal.
TLS / certificados
La gema mantiene la verificación SSL por default (verify: true). No
expone una opción para desactivarla. Si necesitas un sandbox local con
certificados autofirmados, lo más limpio es configurar tu sistema (CA
trust store) en lugar de tocar la gem.
PII en logs
Configuration.logger, si lo configuras, registra los parámetros de cada
request (sin el ticket). Esos parámetros pueden contener RUTs de
proveedores, códigos de organismo y similar — información sujeta a la
Ley 19.628 chilena de protección de datos personales.
Recomendación: en producción usa nivel INFO o más alto y rota logs.
Si tu pipeline manda logs a un servicio externo (Datadog, Loggly, etc.),
configura redacción de PII ahí.
base_url y path son confianza interna
configuration.base_urllo configuras tú en el initializer. Si lo apuntás a un host no oficial, ahí van tus tickets. Como el ticket viaja en la query string, la gema rechazahttp://conConfigurationError(solo se permite hacialocalhost/127.0.0.1, para mocks y tests) — en texto plano el ticket quedaría expuesto a cualquier intermediario.- El parámetro
pathdeClient#getno debe venir de input de usuario. Solo losResources::*lo usan internamente con paths hardcoded. HacerMercadoPublicoCl.client.get(user_input, ...)desde un controller es un SSRF — no lo hagas.
Tamaño de response sin límite
PurchaseOrder.all puede traer ~19.000 items en un día. No hay límite
configurable de tamaño de response. Si necesitas hard-cap para evitar
OOM, ponlo en tu capa de aplicación (timeouts, memory limit del worker).
Limitaciones y notas
- Límite diario por ticket: 10.000 solicitudes. Cada ticket de acceso cuenta con un límite diario de 10.000 solicitudes. Este límite no es modificable y tiene como finalidad resguardar la estabilidad del servicio. Las personas usuarias se comprometen a no exceder ni eludir estas limitaciones. El uso excesivo o abusivo de la API podrá derivar en la suspensión temporal o el bloqueo permanente del acceso.
- Sin caching interno. Cada llamada va a la red; cachear es responsabilidad del consumidor.
- El ticket es personal. No lo compartas en commits ni en logs.
- Los listados se acotan al día consultado. La API solo devuelve
resultados del día indicado en
fecha. - Solo JSON. No se implementa XML ni JSONP.
Contribuciones
Los pull requests son bienvenidos — revisa CONTRIBUTING.md
para el setup del entorno, el flujo de trabajo (test primero) y las guías
del proyecto.
Licencia
MIT — ver LICENSE.txt.
Fuente de la información sobre límites y condiciones de uso de la API: https://www.chilecompra.cl/api/