Source: src/layer.js

import fetch from 'node-fetch';
import { getGeoServerResponseText, GeoServerResponseError } from './util/geoserver.js';
import AboutClient from './about.js';

/**
 * Client for GeoServer layers
 *
 * @module LayerClient
 */
export default class LayerClient {
  /**
   * Creates a GeoServer REST LayerClient instance.
   *
   * @param {String} url The URL of the GeoServer REST API endpoint
   * @param {String} auth The Basic Authentication string
   */
  constructor(url, auth) {
    this.url = url;
    this.auth = auth;
  }

  /**
   * Returns a GeoServer layer by the given workspace and layer name,
   * e.g. "myWs:myLayer".
   *
   * @param {String} workspace The name of the workspace, can be undefined
   * @param {String} layerName The name of the layer to query
   *
   * @throws Error if request fails
   *
   * @returns {Object} An object with layer information or undefined if it cannot be found
   */
  async get(workspace, layerName) {
    let qualifiedName;
    if (workspace) {
      qualifiedName = `${workspace}:${layerName}`;
    } else {
      qualifiedName = layerName;
    }
    const response = await fetch(this.url + 'layers/' + qualifiedName + '.json', {
      credentials: 'include',
      method: 'GET',
      headers: {
        Authorization: this.auth
      }
    });

    if (!response.ok) {
      const grc = new AboutClient(this.url, this.auth);
      if (await grc.exists()) {
        // GeoServer exists, but requested item does not exist,  we return empty
        return;
      } else {
        // There was a general problem with GeoServer
        const geoServerResponse = await getGeoServerResponseText(response);
        throw new GeoServerResponseError(null, geoServerResponse);
      }
    }
    return response.json();
  }

  /**
   * Sets the attribution text and link of a layer.
   *
   * @param {String} workspace The name of the workspace, can be undefined
   * @param {String} layerName The name of the layer to query
   * @param {String} [attributionText] The attribution text
   * @param {String} [attributionLink] The attribution link
   *
   * @throws Error if request fails
   */
  async modifyAttribution(workspace, layerName, attributionText, attributionLink) {
    let qualifiedName;
    if (workspace) {
      qualifiedName = `${workspace}:${layerName}`;
    } else {
      qualifiedName = layerName;
    }
    // take existing layer properties as template
    const jsonBody = await this.get(workspace, layerName);

    if (!jsonBody || !jsonBody.layer || !jsonBody.layer.attribution) {
      throw new GeoServerResponseError(
        `layer '${workspace}:${layerName}' misses the property 'attribution'`
      );
    }

    // set attribution text and link
    if (attributionText) {
      jsonBody.layer.attribution.title = attributionText;
    }
    if (attributionLink) {
      jsonBody.layer.attribution.href = attributionLink;
    }

    const url = this.url + 'layers/' + qualifiedName + '.json';
    const response = await fetch(url, {
      credentials: 'include',
      method: 'PUT',
      headers: {
        Authorization: this.auth,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(jsonBody)
    });

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }

  /**
   * Returns all layers in the GeoServer.
   *
   * @throws Error if request fails
   *
   * @returns {Object} An object with all layer information
   */
  async getAll() {
    const response = await fetch(this.url + 'layers.json', {
      credentials: 'include',
      method: 'GET',
      headers: {
        Authorization: this.auth
      }
    });

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
    return response.json();
  }

  /**
   * Get all layers of a workspace.
   *
   * @param {String} workspace The workspace
   *
   * @throws Error if request fails
   *
   * @return {Object} An object with the information about the layers
   */
  async getLayers(workspace) {
    const response = await fetch(this.url + 'workspaces/' + workspace + '/layers.json', {
      credentials: 'include',
      method: 'GET',
      headers: {
        Authorization: this.auth
      }
    });

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }

    return await response.json();
  }

  /**
   * Returns information about a cascaded WMS layer.
   *
   * @param {String} workspace The workspace
   * @param {String} datastore The datastore
   * @param {String} layerName The WMS layer name
   *
   * @throws Error if request fails
   *
   * @returns {Object} An object with layer information or undefined if it cannot be found
   */
  async getWmsLayer(workspace, datastore, layerName) {
    const response = await fetch(
      this.url +
        'workspaces/' +
        workspace +
        '/wmsstores/' +
        datastore +
        '/wmslayers/' +
        layerName +
        '.json',
      {
        credentials: 'include',
        method: 'GET',
        headers: {
          Authorization: this.auth
        }
      }
    );

    if (!response.ok) {
      const grc = new AboutClient(this.url, this.auth);
      if (await grc.exists()) {
        // GeoServer exists, but requested item does not exist,  we return empty
        return;
      } else {
        // There was a general problem with GeoServer
        const geoServerResponse = await getGeoServerResponseText(response);
        throw new GeoServerResponseError(null, geoServerResponse);
      }
    }

    return await response.json();
  }

  // TODO: automated test needed
  /**
   * Returns information about a cascaded WMTS layer.
   *
   * @param {String} workspace The workspace
   * @param {String} datastore The datastore
   * @param {String} layerName The WMTS layer name
   *
   * @throws Error if request fails
   *
   * @returns {Object} An object with layer information or undefined if it cannot be found
   */
  async getWmtsLayer(workspace, datastore, layerName) {
    const response = await fetch(
      this.url +
        'workspaces/' +
        workspace +
        '/wmtsstores/' +
        datastore +
        '/layers/' +
        layerName +
        '.json',
      {
        credentials: 'include',
        method: 'GET',
        headers: {
          Authorization: this.auth
        }
      }
    );

    if (!response.ok) {
      const grc = new AboutClient(this.url, this.auth);
      if (await grc.exists()) {
        // GeoServer exists, but requested item does not exist,  we return empty
        return;
      } else {
        // There was a general problem with GeoServer
        const geoServerResponse = await getGeoServerResponseText(response);
        throw new GeoServerResponseError(null, geoServerResponse);
      }
    }

    return await response.json();
  }

  /**
   * Publishes a FeatureType in the default data store of the workspace.
   *
   * @param {String} workspace Workspace to publish FeatureType in
   * @param {String} [nativeName] Native name of FeatureType
   * @param {String} name Published name of FeatureType
   * @param {String} [title] Published title of FeatureType
   * @param {String} [srs="EPSG:4326"] The SRS of the FeatureType
   * @param {String} enabled Flag to enable FeatureType by default
   * @param {String} [abstract] The abstract of the layer
   *
   * @throws Error if request fails
   */
  async publishFeatureTypeDefaultDataStore(
    workspace,
    nativeName,
    name,
    title,
    srs,
    enabled,
    abstract
  ) {
    const body = {
      featureType: {
        name: name,
        nativeName: nativeName || name,
        title: title || name,
        srs: srs || 'EPSG:4326',
        enabled: enabled,
        abstract: abstract || ''
      }
    };

    const response = await fetch(this.url + 'workspaces/' + workspace + '/featuretypes', {
      credentials: 'include',
      method: 'POST',
      headers: {
        Authorization: this.auth,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    });

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }

  /**
   * Publishes a FeatureType in the given data store of the workspace.
   *
   * @param {String} workspace Workspace to publish FeatureType in
   * @param {String} dataStore The datastore where the FeatureType's data is in
   * @param {String} [nativeName] Native name of FeatureType
   * @param {String} name Published name of FeatureType
   * @param {String} [title] Published title of FeatureType
   * @param {String} [srs="EPSG:4326"] The SRS of the FeatureType
   * @param {String} enabled Flag to enable FeatureType by default
   * @param {String} [abstract] The abstract of the layer
   * @param {String} [nativeBoundingBox] The native BoundingBox of the FeatureType (has to be set if no data is in store at creation time)
   *
   * @throws Error if request fails
   */
  async publishFeatureType(
    workspace,
    dataStore,
    nativeName,
    name,
    title,
    srs,
    enabled,
    abstract,
    nativeBoundingBox
  ) {
    // apply CRS info for native BBOX if not provided
    if (nativeBoundingBox && !nativeBoundingBox.crs) {
      nativeBoundingBox.crs = {
        '@class': 'projected',
        $: srs
      };
    }

    const body = {
      featureType: {
        name: name || nativeName,
        nativeName: nativeName,
        title: title || name,
        srs: srs || 'EPSG:4326',
        enabled: enabled,
        abstract: abstract || '',
        nativeBoundingBox: nativeBoundingBox
      }
    };

    const response = await fetch(
      this.url + 'workspaces/' + workspace + '/datastores/' + dataStore + '/featuretypes',
      {
        credentials: 'include',
        method: 'POST',
        headers: {
          Authorization: this.auth,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(body)
      }
    );

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }

  /**
   * Get detailed information about a FeatureType.
   *
   * @param {String} workspace The workspace of the FeatureType
   * @param {String} datastore The datastore of the FeatureType
   * @param {String} name The name of the FeatureType
   *
   * @throws Error if request fails
   *
   * @returns {Object} The object of the FeatureType
   */
  async getFeatureType(workspace, datastore, name) {
    const url =
      this.url +
      'workspaces/' +
      workspace +
      '/datastores/' +
      datastore +
      '/featuretypes/' +
      name +
      '.json';
    const response = await fetch(url, {
      credentials: 'include',
      method: 'GET',
      headers: {
        Authorization: this.auth
      }
    });

    if (!response.ok) {
      const grc = new AboutClient(this.url, this.auth);
      if (await grc.exists()) {
        // GeoServer exists, but requested item does not exist, we return empty
        return;
      } else {
        // There was a general problem with GeoServer
        const geoServerResponse = await getGeoServerResponseText(response);
        throw new GeoServerResponseError(null, geoServerResponse);
      }
    }
    return response.json();
  }

  /**
   *  Publishes a WMS layer.
   *
   * @param {String} workspace Workspace to publish WMS layer in
   * @param {String} dataStore The datastore where the WMS is connected
   * @param {String} nativeName Native name of WMS layer
   * @param {String} [name] Published name of WMS layer
   * @param {String} [title] Published title of WMS layer
   * @param {String} [srs="EPSG:4326"] The SRS of the WMS layer
   * @param {String} enabled Flag to enable WMS layer by default
   * @param {String} [abstract] The abstract of the layer
   *
   * @throws Error if request fails
   */
  async publishWmsLayer(workspace, dataStore, nativeName, name, title, srs, enabled, abstract) {
    const body = {
      wmsLayer: {
        name: name || nativeName,
        nativeName: nativeName,
        title: title || name || nativeName,
        srs: srs || 'EPSG:4326',
        enabled: enabled,
        abstract: abstract || ''
      }
    };

    const response = await fetch(
      this.url + 'workspaces/' + workspace + '/wmsstores/' + dataStore + '/wmslayers',
      {
        credentials: 'include',
        method: 'POST',
        headers: {
          Authorization: this.auth,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(body)
      }
    );

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }

  /**
   * Publishes a raster stored in a database.
   *
   * @param {String} workspace Workspace to publish layer in
   * @param {String} coverageStore The coveragestore where the layer's data is in
   * @param {String} nativeName Native name of raster
   * @param {String} name Published name of layer
   * @param {String} [title] Published title of layer
   * @param {String} [srs="EPSG:4326"] The SRS of the layer
   * @param {String} enabled Flag to enable layer by default
   * @param {String} [abstract] The abstract of the layer
   *
   * @throws Error if request fails
   */
  async publishDbRaster(workspace, coverageStore, nativeName, name, title, srs, enabled, abstract) {
    const body = {
      coverage: {
        name: name || nativeName,
        nativeName: nativeName,
        title: title || name,
        srs: srs,
        enabled: enabled,
        abstract: abstract || ''
      }
    };

    const response = await fetch(
      this.url + 'workspaces/' + workspace + '/coveragestores/' + coverageStore + '/coverages',
      {
        credentials: 'include',
        method: 'POST',
        headers: {
          Authorization: this.auth,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(body)
      }
    );

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }

  /**
   * Deletes a FeatureType.
   *
   * @param {String} workspace Workspace where layer to delete is in
   * @param {String} datastore The datastore where the layer to delete is in
   * @param {String} name Layer to delete
   * @param {Boolean} recurse Flag to enable recursive deletion
   *
   * @throws Error if request fails
   */
  async deleteFeatureType(workspace, datastore, name, recurse) {
    const response = await fetch(
      this.url +
        'workspaces/' +
        workspace +
        '/datastores/' +
        datastore +
        '/featuretypes/' +
        name +
        '?recurse=' +
        recurse,
      {
        credentials: 'include',
        method: 'DELETE',
        headers: {
          Authorization: this.auth
        }
      }
    );

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }

  /**
   * Enables TIME dimension for the given coverage layer.
   *
   * @param {String} workspace Workspace where layer to enable time dimension for is in
   * @param {String} datastore The datastore where the layer to enable time dimension for is in
   * @param {String} name Layer to enable time dimension for
   * @param {String} presentation Presentation type: 'LIST' or 'DISCRETE_INTERVAL' or 'CONTINUOUS_INTERVAL'
   * @param {Number} resolution Resolution in milliseconds, e.g. 3600000 for 1 hour
   * @param {String} defaultValue The default time value, e.g. 'MINIMUM' or 'MAXIMUM' or 'NEAREST' or 'FIXED'
   * @param {Boolean} [nearestMatchEnabled] Enable nearest match
   * @param {Boolean} [rawNearestMatchEnabled] Enable raw nearest match
   * @param {String} [acceptableInterval] Acceptable interval for nearest match, e.g.'PT30M'
   *
   * @throws Error if request fails
   */
  async enableTimeCoverage(
    workspace,
    dataStore,
    name,
    presentation,
    resolution,
    defaultValue,
    nearestMatchEnabled,
    rawNearestMatchEnabled,
    acceptableInterval
  ) {
    const body = {
      coverage: {
        metadata: {
          entry: [
            {
              '@key': 'time',
              dimensionInfo: {
                enabled: true,
                presentation: presentation || 'DISCRETE_INTERVAL',
                resolution: resolution,
                units: 'ISO8601',
                defaultValue: {
                  strategy: defaultValue
                },
                nearestMatchEnabled: nearestMatchEnabled,
                rawNearestMatchEnabled: rawNearestMatchEnabled,
                acceptableInterval: acceptableInterval
              }
            }
          ]
        }
      }
    };

    const url =
      this.url +
      'workspaces/' +
      workspace +
      '/coveragestores/' +
      dataStore +
      '/coverages/' +
      name +
      '.json';
    const response = await fetch(url, {
      credentials: 'include',
      method: 'PUT',
      headers: {
        Authorization: this.auth,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    });

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }

  /**
   * Enables TIME dimension for the given FeatureType layer.
   *
   * @param {String} workspace Workspace containing layer to enable time dimension for
   * @param {String} datastore The datastore containing the FeatureType to enable time dimension for
   * @param {String} name FeatureType to enable time dimension for
   * @param {String} attribute Data column / attribute holding the time values
   * @param {String} presentation Presentation type: 'LIST' or 'DISCRETE_INTERVAL' or 'CONTINUOUS_INTERVAL'
   * @param {Number} resolution Resolution in milliseconds, e.g. 3600000 for 1 hour
   * @param {String} defaultValue The default time value, e.g. 'MINIMUM' or 'MAXIMUM' or 'NEAREST' or 'FIXED'
   * @param {Boolean} [nearestMatchEnabled] Enable nearest match
   * @param {Boolean} [rawNearestMatchEnabled] Enable raw nearest match
   *
   * @throws Error if request fails
   */
  async enableTimeFeatureType(
    workspace,
    dataStore,
    name,
    attribute,
    presentation,
    resolution,
    defaultValue,
    nearestMatchEnabled,
    rawNearestMatchEnabled,
    acceptableInterval
  ) {
    const body = {
      featureType: {
        metadata: {
          entry: [
            {
              '@key': 'time',
              dimensionInfo: {
                attribute: attribute,
                presentation: presentation,
                resolution: resolution,
                units: 'ISO8601',
                defaultValue: {
                  strategy: defaultValue
                },
                nearestMatchEnabled: nearestMatchEnabled,
                rawNearestMatchEnabled: rawNearestMatchEnabled,
                acceptableInterval: acceptableInterval
              }
            }
          ]
        }
      }
    };

    const url =
      this.url +
      'workspaces/' +
      workspace +
      '/datastores/' +
      dataStore +
      '/featuretypes/' +
      name +
      '.json';
    const response = await fetch(url, {
      credentials: 'include',
      method: 'PUT',
      headers: {
        Authorization: this.auth,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    });

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }

  /**
   * Returns a dedicated coverage object.
   *
   * @param {String} workspace Workspace containing the coverage
   * @param {String} coverageStore The coveragestore containing the coverage
   * @param {String} name Coverage to query
   *
   * @throws Error if request fails
   *
   * @returns {Object} An object with coverage information or undefined if it cannot be found
   */
  async getCoverage(workspace, coverageStore, name) {
    const url =
      this.url +
      'workspaces/' +
      workspace +
      '/coveragestores/' +
      coverageStore +
      '/coverages/' +
      name +
      '.json';
    const response = await fetch(url, {
      credentials: 'include',
      method: 'GET',
      headers: {
        Authorization: this.auth
      }
    });

    if (!response.ok) {
      const grc = new AboutClient(this.url, this.auth);
      if (await grc.exists()) {
        // GeoServer exists, but requested item does not exist,  we return empty
        return;
      } else {
        // There was a general problem with GeoServer
        const geoServerResponse = await getGeoServerResponseText(response);
        throw new GeoServerResponseError(null, geoServerResponse);
      }
    }
    return response.json();
  }

  /**
   * Renames the existing bands of a coverage layer.
   *
   * Make sure to provide the same number of bands as existing in the layer.
   *
   * @param {String} workspace Workspace of layer
   * @param {String} datastore The datastore of the layer
   * @param {String} layername The layer name
   * @param {String[]} bandNames An array of the new band names in correct order
   *
   * @throws Error if request fails
   */
  async renameCoverageBands(workspace, dataStore, layername, bandNames) {
    const body = {
      coverage: {
        dimensions: {
          coverageDimension: []
        }
      }
    };

    // dynamically create the body
    bandNames.forEach((bandName) => {
      body.coverage.dimensions.coverageDimension.push({
        name: bandName
      });
    });

    const url =
      this.url +
      'workspaces/' +
      workspace +
      '/coveragestores/' +
      dataStore +
      '/coverages/' +
      layername +
      '.json';
    const response = await fetch(url, {
      credentials: 'include',
      method: 'PUT',
      headers: {
        Authorization: this.auth,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    });

    if (!response.ok) {
      const geoServerResponse = await getGeoServerResponseText(response);
      throw new GeoServerResponseError(null, geoServerResponse);
    }
  }
}