Skip to content

JavaScript Fetch Client

Complete, production-ready JavaScript client for the Danish Parliament API using modern fetch API.

Complete Client Implementation

/**
 * Production-ready Danish Parliament API Client
 * 
 * Features:
 * - Modern fetch API with async/await
 * - Comprehensive error handling
 * - Automatic retry with exponential backoff
 * - Rate limiting and request throttling
 * - Memory-efficient pagination
 * - Browser and Node.js compatible
 */

// Polyfill for Node.js < 18 (uncomment if needed)
// import fetch from 'node-fetch';

class DanishParliamentAPI {
  /**
   * Initialize the API client
   * 
   * @param {Object} options - Configuration options
   * @param {number} options.timeout - Request timeout in milliseconds (default: 30000)
   * @param {number} options.retryAttempts - Number of retry attempts (default: 3)
   * @param {number} options.requestDelay - Minimum delay between requests in ms (default: 100)
   */
  constructor(options = {}) {
    this.baseUrl = 'https://oda.ft.dk/api/';
    this.timeout = options.timeout || 30000;
    this.retryAttempts = options.retryAttempts || 3;
    this.requestDelay = options.requestDelay || 100;
    this.lastRequestTime = 0;
  }

  /**
   * Enforce rate limiting between requests
   */
  async _rateLimit() {
    const elapsed = Date.now() - this.lastRequestTime;
    if (elapsed < this.requestDelay) {
      await this._sleep(this.requestDelay - elapsed);
    }
    this.lastRequestTime = Date.now();
  }

  /**
   * Sleep for specified milliseconds
   */
  _sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  /**
   * Build properly encoded URL with OData parameters
   * 
   * @param {string} entity - Entity name (e.g., 'Sag', 'Aktør')
   * @param {Object} params - OData parameters
   * @returns {string} Complete URL with encoded parameters
   */
  _buildUrl(entity, params = {}) {
    const url = `${this.baseUrl}${entity}`;

    if (Object.keys(params).length === 0) {
      return url;
    }

    const queryParts = [];
    for (const [key, value] of Object.entries(params)) {
      if (value !== null && value !== undefined) {
        // Ensure $ parameters are properly encoded
        const encodedKey = key.startsWith('$') ? 
          encodeURIComponent(key) : key;
        const encodedValue = encodeURIComponent(value);
        queryParts.push(`${encodedKey}=${encodedValue}`);
      }
    }

    return `${url}?${queryParts.join('&')}`;
  }

  /**
   * Make HTTP request with retry logic and error handling
   * 
   * @param {string} url - URL to request
   * @param {number} maxRetries - Override default retry attempts
   * @returns {Promise<Object>} Parsed JSON response
   */
  async _makeRequest(url, maxRetries = this.retryAttempts) {
    await this._rateLimit();

    for (let attempt = 0; attempt < maxRetries; attempt++) {
      try {
        // Create AbortController for timeout
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), this.timeout);

        const response = await fetch(url, {
          signal: controller.signal,
          headers: {
            'Accept': 'application/json',
            'User-Agent': 'DanishParliamentAPI-JS/1.0'
          }
        });

        clearTimeout(timeoutId);

        // Handle HTTP status codes
        if (response.ok) {
          return await response.json();
        }

        switch (response.status) {
          case 400:
            throw new APIError(
              `Invalid query parameters. Check $expand and $filter syntax. URL: ${url}`,
              'INVALID_QUERY'
            );

          case 404:
            if (url.includes('/api/') && url.split('/').length === 5) {
              throw new EntityNotFoundError(`Entity not found: ${url.split('/').pop()}`);
            } else {
              throw new RecordNotFoundError(`Record not found: ${url}`);
            }

          case 501:
            throw new UnsupportedOperationError(
              'Write operations are not supported by this API'
            );

          default:
            throw new APIError(`HTTP ${response.status}: ${response.statusText}`);
        }

      } catch (error) {
        // Handle AbortError (timeout)
        if (error.name === 'AbortError') {
          if (attempt < maxRetries - 1) {
            const waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff
            console.warn(`Request timeout, retrying in ${waitTime}ms... (attempt ${attempt + 1}/${maxRetries})`);
            await this._sleep(waitTime);
            continue;
          }
          throw new NetworkError(`Request timed out after ${this.timeout}ms`);
        }

        // Handle network errors
        if (error.name === 'TypeError' && error.message.includes('fetch')) {
          if (attempt < maxRetries - 1) {
            const waitTime = Math.pow(2, attempt) * 1000;
            console.warn(`Network error, retrying in ${waitTime}ms... (attempt ${attempt + 1}/${maxRetries})`);
            await this._sleep(waitTime);
            continue;
          }
          throw new NetworkError(`Network error: ${error.message}`);
        }

        // Re-throw API errors without retry
        if (error instanceof APIError) {
          throw error;
        }

        // Unknown errors
        throw new NetworkError(`Request failed: ${error.message}`);
      }
    }
  }

  /**
   * Get parliamentary cases (Sag) with optional filtering and expansion
   * 
   * @param {Object} options - Query options
   * @param {number} options.top - Number of records to return (max 100)
   * @param {number} options.skip - Number of records to skip for pagination
   * @param {string} options.filter - OData filter expression
   * @param {string} options.expand - Related entities to include
   * @param {string} options.select - Specific fields to return
   * @param {string} options.orderby - Sort order
   * @returns {Promise<Object>} API response with case data
   * 
   * @example
   * // Get recent climate legislation
   * const cases = await api.getCases({
   *   filter: "substringof('klima', titel)",
   *   expand: "Sagskategori",
   *   top: 50
   * });
   */
  async getCases(options = {}) {
    const { top = 100, skip = 0, filter, expand, select, orderby } = options;

    const params = {
      '$top': Math.min(top, 100), // Enforce 100 record limit
      '$skip': skip
    };

    if (filter) params['$filter'] = filter;
    if (expand) params['$expand'] = expand;
    if (select) params['$select'] = select;
    if (orderby) params['$orderby'] = orderby;

    const url = this._buildUrl('Sag', params);
    return await this._makeRequest(url);
  }

  /**
   * Get parliamentary actors (Aktør) - politicians, committees, ministries
   * 
   * @param {Object} options - Query options
   * @returns {Promise<Object>} API response with actor data
   * 
   * @example
   * // Find all politicians with 'Jensen' in name
   * const actors = await api.getActors({
   *   filter: "substringof('Jensen', navn)"
   * });
   */
  async getActors(options = {}) {
    const { top = 100, skip = 0, filter, expand } = options;

    const params = {
      '$top': Math.min(top, 100),
      '$skip': skip
    };

    if (filter) params['$filter'] = filter;
    if (expand) params['$expand'] = expand;

    const url = this._buildUrl('Aktør', params);
    return await this._makeRequest(url);
  }

  /**
   * Get voting sessions (Afstemning)
   * 
   * @param {Object} options - Query options
   * @returns {Promise<Object>} API response with voting session data
   */
  async getVotingSessions(options = {}) {
    const { top = 100, skip = 0, filter, expand, select } = options;

    const params = {
      '$top': Math.min(top, 100),
      '$skip': skip
    };

    if (filter) params['$filter'] = filter;
    if (expand) params['$expand'] = expand;
    if (select) params['$select'] = select;

    const url = this._buildUrl('Afstemning', params);
    return await this._makeRequest(url);
  }

  /**
   * Get all voting records for a specific politician
   * 
   * @param {string} politicianName - Full name of politician
   * @param {number} limit - Maximum number of votes to return
   * @returns {Promise<Array>} Array of voting records
   * 
   * @example
   * const votes = await api.getVotingRecords("Frank Aaen");
   */
  async getVotingRecords(politicianName, limit = 1000) {
    const allVotes = [];
    let skip = 0;
    const batchSize = 100;

    while (allVotes.length < limit && skip < 10000) { // Safety limit
      const params = {
        '$expand': 'Afstemning,Aktør',
        '$filter': `Aktør/navn eq '${politicianName}'`,
        '$top': batchSize,
        '$skip': skip
      };

      const url = this._buildUrl('Stemme', params);
      const response = await this._makeRequest(url);
      const votes = response.value || [];

      if (votes.length === 0) {
        break;
      }

      allVotes.push(...votes);
      skip += batchSize;
    }

    return allVotes.slice(0, limit);
  }

  /**
   * Get recent changes to parliamentary data
   * 
   * @param {string} entity - Entity to check ('Sag', 'Aktør', 'Afstemning', etc.)
   * @param {number} hoursBack - How many hours back to check
   * @returns {Promise<Object>} Recent changes in the specified entity
   * 
   * @example
   * // Check for cases updated in last 4 hours
   * const recent = await api.getRecentChanges('Sag', 4);
   */
  async getRecentChanges(entity = 'Sag', hoursBack = 24) {
    const cutoffTime = new Date();
    cutoffTime.setHours(cutoffTime.getHours() - hoursBack);
    const isoTime = cutoffTime.toISOString().slice(0, 19); // Remove milliseconds

    const params = {
      '$filter': `opdateringsdato gt datetime'${isoTime}'`,
      '$orderby': 'opdateringsdato desc',
      '$top': 100
    };

    const url = this._buildUrl(entity, params);
    return await this._makeRequest(url);
  }

  /**
   * Get detailed information about a voting session
   * 
   * @param {number} votingId - ID of the voting session (Afstemning)
   * @param {boolean} expandVotes - Whether to include individual vote details
   * @returns {Promise<Object>} Voting session with optional vote details
   */
  async getVotingSessionDetails(votingId, expandVotes = true) {
    const expandParts = ['Møde'];
    if (expandVotes) {
      expandParts.push('Stemme/Aktør');
    }

    const params = {
      '$filter': `id eq ${votingId}`,
      '$expand': expandParts.join(',')
    };

    const url = this._buildUrl('Afstemning', params);
    const response = await this._makeRequest(url);

    if (response.value && response.value.length > 0) {
      return response.value[0];
    } else {
      throw new RecordNotFoundError(`Voting session ${votingId} not found`);
    }
  }

  /**
   * Search parliamentary documents by title
   * 
   * @param {string} searchTerm - Term to search for in document titles
   * @param {boolean} includeFiles - Whether to include file download URLs
   * @returns {Promise<Object>} Matching documents
   */
  async searchDocuments(searchTerm, includeFiles = false) {
    const params = {
      '$filter': `substringof('${searchTerm}', titel)`,
      '$top': 100
    };

    if (includeFiles) {
      params['$expand'] = 'Fil';
    }

    const url = this._buildUrl('Dokument', params);
    return await this._makeRequest(url);
  }

  /**
   * Get total count of records in an entity
   * 
   * @param {string} entity - Entity name
   * @returns {Promise<number>} Total number of records
   */
  async getEntityCount(entity) {
    const params = {
      '$inlinecount': 'allpages',
      '$top': 1
    };

    const url = this._buildUrl(entity, params);
    const response = await this._makeRequest(url);

    const countStr = response['odata.count'] || '0';
    return parseInt(countStr, 10);
  }

  /**
   * Generic request method for custom queries
   * 
   * @param {string} entity - Entity name
   * @param {Object} params - OData parameters
   * @returns {Promise<Object>} API response
   */
  async request(entity, params = {}) {
    const url = this._buildUrl(entity, params);
    return await this._makeRequest(url);
  }

  /**
   * Async generator for paginating through all records
   * 
   * @param {string} entity - Entity name
   * @param {Object} options - Pagination options
   * @param {number} options.batchSize - Records per batch (max 100)
   * @param {number} options.maxRecords - Maximum total records to fetch
   * @param {Object} options.params - Additional OData parameters
   * 
   * @example
   * // Process all climate cases
   * for await (const case of api.paginateAll('Sag', {
   *   params: { '$filter': "substringof('klima', titel)" },
   *   maxRecords: 500
   * })) {
   *   console.log(case.titel);
   * }
   */
  async* paginateAll(entity, options = {}) {
    const { batchSize = 100, maxRecords = Infinity, params = {} } = options;

    let skip = 0;
    let totalYielded = 0;
    const safeBatchSize = Math.min(batchSize, 100);

    while (totalYielded < maxRecords && skip < 100000) { // Safety limit
      const requestParams = {
        ...params,
        '$top': safeBatchSize,
        '$skip': skip
      };

      try {
        const url = this._buildUrl(entity, requestParams);
        const response = await this._makeRequest(url);
        const records = response.value || [];

        if (records.length === 0) {
          break; // No more records
        }

        // Yield each record individually
        for (const record of records) {
          if (totalYielded >= maxRecords) {
            return;
          }
          yield record;
          totalYielded++;
        }

        skip += safeBatchSize;

      } catch (error) {
        console.error(`Error paginating ${entity} at skip=${skip}:`, error);
        break;
      }
    }
  }

  /**
   * Batch multiple requests concurrently
   * 
   * @param {Array} requests - Array of request configurations
   * @param {number} maxConcurrent - Maximum concurrent requests
   * @returns {Promise<Array>} Array of responses
   * 
   * @example
   * const results = await api.batchRequests([
   *   { entity: 'Sag', params: { '$top': 10 } },
   *   { entity: 'Aktør', params: { '$top': 5 } },
   *   { entity: 'Afstemning', params: { '$top': 3 } }
   * ]);
   */
  async batchRequests(requests, maxConcurrent = 5) {
    // Limit concurrent requests to be respectful to the API
    const semaphore = new Semaphore(maxConcurrent);

    const executeRequest = async (request) => {
      await semaphore.acquire();
      try {
        const url = this._buildUrl(request.entity, request.params || {});
        return await this._makeRequest(url);
      } finally {
        semaphore.release();
      }
    };

    return await Promise.all(requests.map(executeRequest));
  }

  /**
   * Analyze voting patterns for a set of votes
   * 
   * @param {Array} votes - Array of voting records
   * @returns {Object} Voting analysis
   */
  analyzeVotingPatterns(votes) {
    const analysis = {
      totalVotes: votes.length,
      voteTypes: {},
      partiesVotedWith: {},
      timeSpan: { earliest: null, latest: null }
    };

    for (const vote of votes) {
      // Count vote types
      const voteType = vote.typeid;
      analysis.voteTypes[voteType] = (analysis.voteTypes[voteType] || 0) + 1;

      // Track time span
      const voteDate = new Date(vote.Afstemning?.dato || '1900-01-01');
      if (!analysis.timeSpan.earliest || voteDate < analysis.timeSpan.earliest) {
        analysis.timeSpan.earliest = voteDate;
      }
      if (!analysis.timeSpan.latest || voteDate > analysis.timeSpan.latest) {
        analysis.timeSpan.latest = voteDate;
      }
    }

    return analysis;
  }
}

// Helper class for limiting concurrent requests
class Semaphore {
  constructor(max) {
    this.max = max;
    this.current = 0;
    this.queue = [];
  }

  async acquire() {
    return new Promise((resolve) => {
      if (this.current < this.max) {
        this.current++;
        resolve();
      } else {
        this.queue.push(resolve);
      }
    });
  }

  release() {
    this.current--;
    if (this.queue.length > 0) {
      this.current++;
      const resolve = this.queue.shift();
      resolve();
    }
  }
}

// Custom Error Classes
class APIError extends Error {
  constructor(message, code = 'API_ERROR') {
    super(message);
    this.name = 'APIError';
    this.code = code;
  }
}

class NetworkError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NetworkError';
  }
}

class EntityNotFoundError extends APIError {
  constructor(message) {
    super(message, 'ENTITY_NOT_FOUND');
    this.name = 'EntityNotFoundError';
  }
}

class RecordNotFoundError extends APIError {
  constructor(message) {
    super(message, 'RECORD_NOT_FOUND');
    this.name = 'RecordNotFoundError';
  }
}

class UnsupportedOperationError extends APIError {
  constructor(message) {
    super(message, 'UNSUPPORTED_OPERATION');
    this.name = 'UnsupportedOperationError';
  }
}

// Export for ES6 modules
export {
  DanishParliamentAPI,
  APIError,
  NetworkError,
  EntityNotFoundError,
  RecordNotFoundError,
  UnsupportedOperationError
};

// Export for CommonJS (Node.js)
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    DanishParliamentAPI,
    APIError,
    NetworkError,
    EntityNotFoundError,
    RecordNotFoundError,
    UnsupportedOperationError
  };
}

// Usage Examples
if (typeof window === 'undefined') { // Node.js environment
  // Example usage
  (async () => {
    try {
      const api = new DanishParliamentAPI();

      // Get recent cases
      console.log('Getting recent cases...');
      const cases = await api.getCases({ top: 5 });
      console.log(`Found ${cases.value.length} cases`);

      // Search for climate legislation
      console.log('\nSearching for climate legislation...');
      const climateCases = await api.getCases({
        filter: "substringof('klima', titel)",
        top: 10
      });
      console.log(`Found ${climateCases.value.length} climate-related cases`);

      // Get total case count
      console.log('\nGetting total case count...');
      const totalCases = await api.getEntityCount('Sag');
      console.log(`Total cases in database: ${totalCases.toLocaleString()}`);

      // Get recent changes
      console.log('\nChecking recent changes...');
      const recent = await api.getRecentChanges('Sag', 24);
      console.log(`Cases updated in last 24 hours: ${recent.value.length}`);

    } catch (error) {
      if (error instanceof APIError) {
        console.error('API Error:', error.message);
      } else if (error instanceof NetworkError) {
        console.error('Network Error:', error.message);
      } else {
        console.error('Unexpected error:', error);
      }
    }
  })();
}

Key Features

1. Modern JavaScript

  • Uses native fetch() API (no dependencies)
  • Async/await throughout for clean code
  • ES6 classes with proper encapsulation
  • Comprehensive JSDoc documentation

2. Error Handling

  • Custom error classes for different failure types
  • Automatic retry with exponential backoff
  • Timeout handling with AbortController
  • Graceful degradation for network issues

3. Performance Optimizations

  • Built-in rate limiting to respect API
  • Connection reuse through fetch API
  • Efficient pagination with generators
  • Concurrent request batching with limits

4. Production Ready

  • TypeScript-compatible (JSDoc types)
  • Works in both browser and Node.js
  • Comprehensive test coverage potential
  • Configurable timeout and retry settings

Installation & Setup

Browser Usage

<!DOCTYPE html>
<html>
<head>
  <title>Danish Parliament API Example</title>
</head>
<body>
  <script type="module">
    import { DanishParliamentAPI } from './danish-parliament-api.js';

    const api = new DanishParliamentAPI();

    // Your code here
    api.getCases({ top: 10 }).then(cases => {
      console.log('Cases:', cases.value);
    });
  </script>
</body>
</html>

Node.js Usage

// For Node.js 18+
const { DanishParliamentAPI } = require('./danish-parliament-api.js');

// For older Node.js versions, install node-fetch first:
// npm install node-fetch
// Then uncomment the import at the top of the file

const api = new DanishParliamentAPI({
  timeout: 60000,        // 60 seconds
  retryAttempts: 5,      // 5 retry attempts
  requestDelay: 200      // 200ms between requests
});

async function main() {
  try {
    const cases = await api.getCases({ top: 100 });
    console.log(`Found ${cases.value.length} cases`);
  } catch (error) {
    console.error('Error:', error.message);
  }
}

main();

This implementation provides a robust, production-ready JavaScript client that handles all the complexities of the Danish Parliament API while providing a clean, modern interface.