"use strict";
// The MIT License (MIT)
//
// Copyright (c) 2017 Firebase
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
Object.defineProperty(exports, "__esModule", { value: true });
const cors = require("cors");
const _ = require("lodash");
const apps_1 = require("../apps");
const cloud_functions_1 = require("../cloud-functions");
/**
 * Handle HTTP requests.
 * @param handler A function that takes a request and response object,
 * same signature as an Express app.
 */
function onRequest(handler) {
    return _onRequestWithOptions(handler, {});
}
exports.onRequest = onRequest;
/**
 * Declares a callable method for clients to call using a Firebase SDK.
 * @param handler A method that takes a data and context and returns a value.
 */
function onCall(handler) {
    return _onCallWithOptions(handler, {});
}
exports.onCall = onCall;
/** @hidden */
function _onRequestWithOptions(handler, options) {
    // lets us add __trigger without altering handler:
    const cloudFunction = (req, res) => {
        handler(req, res);
    };
    cloudFunction.__trigger = _.assign(cloud_functions_1.optionsToTrigger(options), {
        httpsTrigger: {},
    });
    // TODO parse the options
    return cloudFunction;
}
exports._onRequestWithOptions = _onRequestWithOptions;
/**
 * Standard error codes and HTTP statuses for different ways a request can fail,
 * as defined by:
 * https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
 *
 * This map is used primarily to convert from a client error code string to
 * to the HTTP format error code string and status, and make sure it's in the
 * supported set.
 */
const errorCodeMap = {
    ok: { canonicalName: 'OK', status: 200 },
    cancelled: { canonicalName: 'CANCELLED', status: 499 },
    unknown: { canonicalName: 'UNKNOWN', status: 500 },
    'invalid-argument': { canonicalName: 'INVALID_ARGUMENT', status: 400 },
    'deadline-exceeded': { canonicalName: 'DEADLINE_EXCEEDED', status: 504 },
    'not-found': { canonicalName: 'NOT_FOUND', status: 404 },
    'already-exists': { canonicalName: 'ALREADY_EXISTS', status: 409 },
    'permission-denied': { canonicalName: 'PERMISSION_DENIED', status: 403 },
    unauthenticated: { canonicalName: 'UNAUTHENTICATED', status: 401 },
    'resource-exhausted': { canonicalName: 'RESOURCE_EXHAUSTED', status: 429 },
    'failed-precondition': { canonicalName: 'FAILED_PRECONDITION', status: 400 },
    aborted: { canonicalName: 'ABORTED', status: 409 },
    'out-of-range': { canonicalName: 'OUT_OF_RANGE', status: 400 },
    unimplemented: { canonicalName: 'UNIMPLEMENTED', status: 501 },
    internal: { canonicalName: 'INTERNAL', status: 500 },
    unavailable: { canonicalName: 'UNAVAILABLE', status: 503 },
    'data-loss': { canonicalName: 'DATA_LOSS', status: 500 },
};
/**
 * An explicit error that can be thrown from a handler to send an error to the
 * client that called the function.
 */
class HttpsError extends Error {
    constructor(code, message, details) {
        super(message);
        // A sanity check for non-TypeScript consumers.
        if (code in errorCodeMap === false) {
            throw new Error(`Unknown error code: ${code}.`);
        }
        this.code = code;
        this.details = details;
        this.httpErrorCode = errorCodeMap[code];
    }
    /** @hidden */
    toJSON() {
        const { details, httpErrorCode: { canonicalName: status }, message, } = this;
        return Object.assign(Object.assign({}, (details === undefined ? {} : { details })), { message,
            status });
    }
}
exports.HttpsError = HttpsError;
// Returns true if req is a properly formatted callable request.
function isValidRequest(req) {
    // The body must not be empty.
    if (!req.body) {
        console.warn('Request is missing body.');
        return false;
    }
    // Make sure it's a POST.
    if (req.method !== 'POST') {
        console.warn('Request has invalid method.', req.method);
        return false;
    }
    // Check that the Content-Type is JSON.
    let contentType = (req.header('Content-Type') || '').toLowerCase();
    // If it has a charset, just ignore it for now.
    const semiColon = contentType.indexOf(';');
    if (semiColon >= 0) {
        contentType = contentType.substr(0, semiColon).trim();
    }
    if (contentType !== 'application/json') {
        console.warn('Request has incorrect Content-Type.', contentType);
        return false;
    }
    // The body must have data.
    if (_.isUndefined(req.body.data)) {
        console.warn('Request body is missing data.', req.body);
        return false;
    }
    // TODO(klimt): Allow only whitelisted http headers.
    // Verify that the body does not have any extra fields.
    const extras = _.omit(req.body, 'data');
    if (!_.isEmpty(extras)) {
        console.warn('Request body has extra fields.', extras);
        return false;
    }
    return true;
}
const LONG_TYPE = 'type.googleapis.com/google.protobuf.Int64Value';
const UNSIGNED_LONG_TYPE = 'type.googleapis.com/google.protobuf.UInt64Value';
/**
 * Encodes arbitrary data in our special format for JSON.
 * This is exposed only for testing.
 */
/** @hidden */
function encode(data) {
    if (_.isNull(data) || _.isUndefined(data)) {
        return null;
    }
    // Oddly, _.isFinite(new Number(x)) always returns false, so unwrap Numbers.
    if (data instanceof Number) {
        data = data.valueOf();
    }
    if (_.isFinite(data)) {
        // Any number in JS is safe to put directly in JSON and parse as a double
        // without any loss of precision.
        return data;
    }
    if (_.isBoolean(data)) {
        return data;
    }
    if (_.isString(data)) {
        return data;
    }
    if (_.isArray(data)) {
        return _.map(data, encode);
    }
    if (_.isObject(data)) {
        // It's not safe to use _.forEach, because the object might be 'array-like'
        // if it has a key called 'length'. Note that this intentionally overrides
        // any toJSON method that an object may have.
        return _.mapValues(data, encode);
    }
    // If we got this far, the data is not encodable.
    console.error('Data cannot be encoded in JSON.', data);
    throw new Error('Data cannot be encoded in JSON: ' + data);
}
exports.encode = encode;
/**
 * Decodes our special format for JSON into native types.
 * This is exposed only for testing.
 */
/** @hidden */
function decode(data) {
    if (data === null) {
        return data;
    }
    if (data['@type']) {
        switch (data['@type']) {
            case LONG_TYPE:
            // Fall through and handle this the same as unsigned.
            case UNSIGNED_LONG_TYPE: {
                // Technically, this could work return a valid number for malformed
                // data if there was a number followed by garbage. But it's just not
                // worth all the extra code to detect that case.
                const value = parseFloat(data.value);
                if (_.isNaN(value)) {
                    console.error('Data cannot be decoded from JSON.', data);
                    throw new Error('Data cannot be decoded from JSON: ' + data);
                }
                return value;
            }
            default: {
                console.error('Data cannot be decoded from JSON.', data);
                throw new Error('Data cannot be decoded from JSON: ' + data);
            }
        }
    }
    if (_.isArray(data)) {
        return _.map(data, decode);
    }
    if (_.isObject(data)) {
        // It's not safe to use _.forEach, because the object might be 'array-like'
        // if it has a key called 'length'.
        return _.mapValues(data, decode);
    }
    // Anything else is safe to return.
    return data;
}
exports.decode = decode;
const corsHandler = cors({ origin: true, methods: 'POST' });
/** @hidden */
function _onCallWithOptions(handler, options) {
    const func = async (req, res) => {
        try {
            if (!isValidRequest(req)) {
                console.error('Invalid request', req);
                throw new HttpsError('invalid-argument', 'Bad Request');
            }
            const context = { rawRequest: req };
            const authorization = req.header('Authorization');
            if (authorization) {
                const match = authorization.match(/^Bearer (.*)$/);
                if (!match) {
                    throw new HttpsError('unauthenticated', 'Unauthenticated');
                }
                const idToken = match[1];
                try {
                    const authToken = await apps_1.apps()
                        .admin.auth()
                        .verifyIdToken(idToken);
                    context.auth = {
                        uid: authToken.uid,
                        token: authToken,
                    };
                }
                catch (e) {
                    throw new HttpsError('unauthenticated', 'Unauthenticated');
                }
            }
            const instanceId = req.header('Firebase-Instance-ID-Token');
            if (instanceId) {
                // Validating the token requires an http request, so we don't do it.
                // If the user wants to use it for something, it will be validated then.
                // Currently, the only real use case for this token is for sending
                // pushes with FCM. In that case, the FCM APIs will validate the token.
                context.instanceIdToken = req.header('Firebase-Instance-ID-Token');
            }
            const data = decode(req.body.data);
            let result = await handler(data, context);
            // Encode the result as JSON to preserve types like Dates.
            result = encode(result);
            // If there was some result, encode it in the body.
            const responseBody = { result };
            res.status(200).send(responseBody);
        }
        catch (error) {
            if (!(error instanceof HttpsError)) {
                // This doesn't count as an 'explicit' error.
                console.error('Unhandled error', error);
                error = new HttpsError('internal', 'INTERNAL');
            }
            const { status } = error.httpErrorCode;
            const body = { error: error.toJSON() };
            res.status(status).send(body);
        }
    };
    // Wrap the function with a cors handler.
    const corsFunc = (req, res) => {
        return corsHandler(req, res, () => func(req, res));
    };
    corsFunc.__trigger = _.assign(cloud_functions_1.optionsToTrigger(options), {
        httpsTrigger: {},
        labels: { 'deployment-callable': 'true' },
    });
    corsFunc.run = handler;
    return corsFunc;
}
exports._onCallWithOptions = _onCallWithOptions;
